diff --git a/.DS_Store b/.DS_Store index 2fff9d3..1d2918f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/AR_MEASUREMENT_TOOL_IMPLEMENTATION_PLAN.md b/AR_MEASUREMENT_TOOL_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6db2b73 --- /dev/null +++ b/AR_MEASUREMENT_TOOL_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1465 @@ +# EnVision - AR Measurement Tool Implementation Plan +*Complete Guide for Adding Measurement Features to Room & Furniture Viewing* + +--- + +## Table of Contents +1. [Overview](#1-overview) +2. [Measurement Types](#2-measurement-types) +3. [Technical Architecture](#3-technical-architecture) +4. [UI/UX Design](#4-uiux-design) +5. [RealityKit Implementation](#5-realitykit-implementation) +6. [ARKit Implementation (Alternative)](#6-arkit-implementation-alternative) +7. [Measurement Precision](#7-measurement-precision) +8. [Code Implementation](#8-code-implementation) +9. [Testing Plan](#9-testing-plan) +10. [Enhancement Ideas](#10-enhancement-ideas) + +--- + +## 1. Overview + +### 1.1 Purpose +Add real-world measurement capabilities to EnVision's 3D viewing experience, allowing users to: +- Measure distances between furniture pieces +- Measure dimensions of individual furniture items +- Measure room dimensions (walls, floor area, ceiling height) +- Verify furniture will fit in specific locations +- Plan room layouts with accurate spacing + +### 1.2 User Stories +1. **"Will this sofa fit?"** - User measures sofa length and compares to wall space +2. **"How much clearance?"** - User measures distance between furniture pieces +3. **"Room dimensions?"** - User measures room walls to verify model accuracy +4. **"Height check"** - User measures furniture height to ensure it fits under shelves +5. **"Floor area"** - User measures available floor space for new furniture + +### 1.3 Where to Add Measurements + +**Existing View Controllers**: +- ✅ `RoomViewerViewController.swift` - View room with placed furniture (PRIORITY) +- ✅ `RoomVisualizeVC.swift` - 3D room visualization +- ⚠️ `MyRoomsViewController.swift` - Could show room dimensions in list view +- ⚠️ `ScanFurnitureViewController.swift` - Could show furniture dimensions + +### 1.4 Key Features +- **Point-to-Point Measurement**: Tap two points, see distance +- **Object Dimension Display**: Show width × length × height for selected furniture +- **Room Dimension Display**: Show room measurements (from RoomPlan metadata) +- **Measurement History**: Keep list of recent measurements +- **Units Toggle**: Metric (meters/cm) ↔ Imperial (feet/inches) +- **Export Measurements**: Share as text or screenshot + +--- + +## 2. Measurement Types + +### 2.1 Point-to-Point Distance +**Use Case**: "How far is this chair from that table?" + +**Visual**: +``` + Start Point ●─────────────● End Point + └─ 2.45 m ─┘ +``` + +**Interaction**: +1. Tap "Measure" button +2. Tap first location +3. Tap second location +4. Distance shown in floating label + +### 2.2 Furniture Dimensions +**Use Case**: "How big is this chair?" + +**Visual**: +``` + ┌─────┐ + │ │ Height: 0.92 m + │ │ + └─────┘ + Width: 0.65 m + Depth: 0.58 m +``` + +**Interaction**: +1. Tap furniture item +2. Show bounding box with dimensions +3. Display width × depth × height + +### 2.3 Room Dimensions +**Use Case**: "What are the exact room measurements?" + +**Visual**: +``` + 5.2 m + ┌───────────┐ + │ │ 4.1 m + │ Room │ + │ │ + └───────────┘ + Height: 2.7 m + Area: 21.3 m² +``` + +**Interaction**: +1. Tap "Room Info" button +2. Show overlay with dimensions from RoomPlan metadata +3. Display floor area calculation + +### 2.4 Multiple Measurements +**Use Case**: "Compare several distances" + +**Visual**: +``` + ●─1.2m─● Table + │ + 0.8m + │ + ●─1.5m─● Chair +``` + +**Interaction**: +1. Create multiple measurements +2. Label each measurement +3. Show measurement list panel +4. Delete individual measurements + +--- + +## 3. Technical Architecture + +### 3.1 Core Components + +```swift +// MARK: - Measurement Data Model +struct Measurement { + let id: UUID + let type: MeasurementType + let startPoint: SIMD3 + let endPoint: SIMD3 + let distance: Float + let label: String? + let timestamp: Date +} + +enum MeasurementType { + case pointToPoint + case objectDimensions + case roomDimensions +} + +// MARK: - Measurement Manager +class MeasurementManager { + var measurements: [Measurement] = [] + var isActive: Bool = false + var currentUnit: MeasurementUnit = .metric + + func addMeasurement(_ measurement: Measurement) + func removeMeasurement(id: UUID) + func clearAllMeasurements() + func formatDistance(_ meters: Float) -> String +} + +// MARK: - Measurement Visualizer (RealityKit) +class MeasurementVisualizer { + func createLine(from start: SIMD3, to end: SIMD3) -> ModelEntity + func createLabel(text: String, at position: SIMD3) -> ModelEntity + func createBoundingBox(for entity: ModelEntity) -> ModelEntity +} +``` + +### 3.2 Flow Diagram + +``` +User taps "Measure" button + ↓ +Measurement mode activated + ↓ +User taps first point (raycast to detect surface/object) + ↓ +Show start point indicator ● + ↓ +User moves finger (show live line + distance) + ↓ +User taps second point + ↓ +Calculate distance (Euclidean) + ↓ +Create permanent line + label (RealityKit entity) + ↓ +Add to measurements list + ↓ +Measurement mode stays active (repeat) or exit +``` + +--- + +## 4. UI/UX Design + +### 4.1 Measurement Controls Panel + +**Location**: Bottom of screen (above furniture picker if present) + +``` +┌────────────────────────────────────┐ +│ [📏 Measure] [📐 Dimensions] [ℹ️ Info] │ +│ │ +│ [Clear All] [m ↔ ft] [Export] │ +└────────────────────────────────────┘ +``` + +**Buttons**: +- **📏 Measure**: Toggle point-to-point mode +- **📐 Dimensions**: Show furniture dimensions +- **ℹ️ Info**: Show room info panel +- **Clear All**: Remove all measurements +- **m ↔ ft**: Toggle units +- **Export**: Share screenshot + text list + +### 4.2 Measurement Display Styles + +**Label Style** (floating above line): +``` +╔═══════════╗ +║ 2.45 m ║ +╚═══════════╝ +``` + +**Properties**: +- Background: Semi-transparent white/dark (adapts to scene) +- Text: Bold, 16pt +- Border: 1pt stroke +- Shadow: Subtle drop shadow for depth + +### 4.3 Visual Elements + +**Point Indicator**: +- Sphere: 0.05m radius +- Color: System blue (bright, visible) +- Material: Unlit (always visible) + +**Measurement Line**: +- Cylinder: 0.01m radius +- Color: System blue +- Material: Unlit +- Dashed style (optional) + +**Bounding Box** (for furniture dimensions): +- Wireframe box around object +- Color: System green +- Line width: 2pt +- Corner spheres for anchors + +### 4.4 Interaction States + +**Idle** (default): +- No measurement mode active +- No visual overlays + +**Measuring** (active): +- First point placed → blue sphere +- Moving finger → live line follows +- Second point placed → line + label created + +**Selected** (tap existing measurement): +- Highlight measurement in yellow +- Show edit/delete buttons +- Allow repositioning endpoints + +--- + +## 5. RealityKit Implementation + +### 5.1 Create MeasurementManager + +**File**: `Envision/Managers/MeasurementManager.swift` + +```swift +import Foundation +import RealityKit + +final class MeasurementManager { + static let shared = MeasurementManager() + + enum MeasurementUnit { + case metric // meters/centimeters + case imperial // feet/inches + } + + private(set) var measurements: [Measurement] = [] + private(set) var isActive: Bool = false + var currentUnit: MeasurementUnit = .metric { + didSet { + // Notify UI to refresh labels + NotificationCenter.default.post(name: .measurementUnitChanged, object: nil) + } + } + + private init() {} + + // MARK: - Measurement CRUD + + func addMeasurement(_ measurement: Measurement) { + measurements.append(measurement) + NotificationCenter.default.post(name: .measurementAdded, object: measurement) + } + + func removeMeasurement(id: UUID) { + measurements.removeAll { $0.id == id } + NotificationCenter.default.post(name: .measurementRemoved, object: id) + } + + func clearAllMeasurements() { + measurements.removeAll() + NotificationCenter.default.post(name: .measurementsCleared, object: nil) + } + + // MARK: - Activation + + func activateMeasurementMode() { + isActive = true + } + + func deactivateMeasurementMode() { + isActive = false + } + + // MARK: - Formatting + + func formatDistance(_ meters: Float) -> String { + switch currentUnit { + case .metric: + if meters < 1.0 { + return String(format: "%.0f cm", meters * 100) + } else { + return String(format: "%.2f m", meters) + } + case .imperial: + let feet = meters * 3.28084 + let totalInches = feet * 12 + let feetPart = Int(totalInches / 12) + let inchesPart = totalInches.truncatingRemainder(dividingBy: 12) + return String(format: "%d' %.1f\"", feetPart, inchesPart) + } + } + + func formatArea(_ squareMeters: Float) -> String { + switch currentUnit { + case .metric: + return String(format: "%.2f m²", squareMeters) + case .imperial: + let sqFeet = squareMeters * 10.7639 + return String(format: "%.2f ft²", sqFeet) + } + } + + // MARK: - Export + + func exportMeasurementsAsText() -> String { + var text = "EnVision Measurements\n" + text += "Date: \(Date().formatted())\n\n" + + for (index, measurement) in measurements.enumerated() { + text += "\(index + 1). " + text += "\(measurement.label ?? "Measurement"): " + text += formatDistance(measurement.distance) + text += "\n" + } + + return text + } +} + +// MARK: - Notification Names +extension Notification.Name { + static let measurementAdded = Notification.Name("measurementAdded") + static let measurementRemoved = Notification.Name("measurementRemoved") + static let measurementsCleared = Notification.Name("measurementsCleared") + static let measurementUnitChanged = Notification.Name("measurementUnitChanged") +} + +// MARK: - Measurement Model +struct Measurement { + let id: UUID + let type: MeasurementType + let startPoint: SIMD3 + let endPoint: SIMD3 + var distance: Float { + return simd_distance(startPoint, endPoint) + } + var label: String? + let timestamp: Date + + init(type: MeasurementType, startPoint: SIMD3, endPoint: SIMD3, label: String? = nil) { + self.id = UUID() + self.type = type + self.startPoint = startPoint + self.endPoint = endPoint + self.label = label + self.timestamp = Date() + } +} + +enum MeasurementType { + case pointToPoint + case objectDimensions + case roomDimensions +} +``` + +### 5.2 Create MeasurementVisualizer + +**File**: `Envision/Managers/MeasurementVisualizer.swift` + +```swift +import Foundation +import RealityKit + +final class MeasurementVisualizer { + + // MARK: - Create Point Indicator + + func createPointIndicator(at position: SIMD3) -> ModelEntity { + let sphere = MeshResource.generateSphere(radius: 0.02) // 2cm sphere + + var material = UnlitMaterial() + material.color = .init(tint: .systemBlue) + + let entity = ModelEntity(mesh: sphere, materials: [material]) + entity.position = position + entity.name = "measurementPoint" + + return entity + } + + // MARK: - Create Line Between Points + + func createLine(from start: SIMD3, to end: SIMD3) -> ModelEntity { + let distance = simd_distance(start, end) + + // Create cylinder as line + let cylinder = MeshResource.generateCylinder(height: distance, radius: 0.005) // 0.5cm thick + + var material = UnlitMaterial() + material.color = .init(tint: .systemBlue) + + let entity = ModelEntity(mesh: cylinder, materials: [material]) + + // Position at midpoint + let midpoint = (start + end) / 2 + entity.position = midpoint + + // Rotate to align with start-end vector + let direction = end - start + let up = SIMD3(0, 1, 0) + + // Calculate rotation to align Y-axis with direction + let rotationAxis = simd_cross(up, simd_normalize(direction)) + let rotationAngle = acos(simd_dot(up, simd_normalize(direction))) + + if simd_length(rotationAxis) > 0.001 { + entity.orientation = simd_quatf(angle: rotationAngle, axis: simd_normalize(rotationAxis)) + } + + entity.name = "measurementLine" + + return entity + } + + // MARK: - Create Text Label + + func createLabel(text: String, at position: SIMD3, billboarding: Bool = true) -> ModelEntity { + // Create text mesh + let textMesh = MeshResource.generateText( + text, + extrusionDepth: 0.001, + font: .systemFont(ofSize: 0.05), // 5cm font size + containerFrame: .zero, + alignment: .center, + lineBreakMode: .byWordWrapping + ) + + var material = UnlitMaterial() + material.color = .init(tint: .white) + + let textEntity = ModelEntity(mesh: textMesh, materials: [material]) + textEntity.position = position + SIMD3(0, 0.1, 0) // Offset above line + + // Add background panel + let panelWidth: Float = 0.3 + let panelHeight: Float = 0.08 + let panel = MeshResource.generatePlane(width: panelWidth, height: panelHeight) + + var panelMaterial = UnlitMaterial() + panelMaterial.color = .init(tint: UIColor.black.withAlphaComponent(0.7)) + + let panelEntity = ModelEntity(mesh: panel, materials: [panelMaterial]) + panelEntity.position = SIMD3(0, 0, -0.002) // Behind text + + // Group text and panel + let labelGroup = ModelEntity() + labelGroup.addChild(panelEntity) + labelGroup.addChild(textEntity) + labelGroup.position = position + labelGroup.name = "measurementLabel" + + // Optional: Make label face camera (billboarding) + if billboarding { + // Will need to update orientation each frame (see below) + } + + return labelGroup + } + + // MARK: - Create Bounding Box (for furniture dimensions) + + func createBoundingBox(for entity: ModelEntity) -> ModelEntity { + let bounds = entity.visualBounds(relativeTo: nil) + let size = bounds.extents + + // Create wireframe box + let boxGroup = ModelEntity() + boxGroup.name = "boundingBox" + + // Create 12 edges of the box + let edges: [(SIMD3, SIMD3)] = [ + // Bottom face + (SIMD3(-size.x/2, -size.y/2, -size.z/2), SIMD3(size.x/2, -size.y/2, -size.z/2)), + (SIMD3(size.x/2, -size.y/2, -size.z/2), SIMD3(size.x/2, -size.y/2, size.z/2)), + (SIMD3(size.x/2, -size.y/2, size.z/2), SIMD3(-size.x/2, -size.y/2, size.z/2)), + (SIMD3(-size.x/2, -size.y/2, size.z/2), SIMD3(-size.x/2, -size.y/2, -size.z/2)), + // Top face + (SIMD3(-size.x/2, size.y/2, -size.z/2), SIMD3(size.x/2, size.y/2, -size.z/2)), + (SIMD3(size.x/2, size.y/2, -size.z/2), SIMD3(size.x/2, size.y/2, size.z/2)), + (SIMD3(size.x/2, size.y/2, size.z/2), SIMD3(-size.x/2, size.y/2, size.z/2)), + (SIMD3(-size.x/2, size.y/2, size.z/2), SIMD3(-size.x/2, size.y/2, -size.z/2)), + // Vertical edges + (SIMD3(-size.x/2, -size.y/2, -size.z/2), SIMD3(-size.x/2, size.y/2, -size.z/2)), + (SIMD3(size.x/2, -size.y/2, -size.z/2), SIMD3(size.x/2, size.y/2, -size.z/2)), + (SIMD3(size.x/2, -size.y/2, size.z/2), SIMD3(size.x/2, size.y/2, size.z/2)), + (SIMD3(-size.x/2, -size.y/2, size.z/2), SIMD3(-size.x/2, size.y/2, size.z/2)) + ] + + for (start, end) in edges { + let line = createLine(from: start, to: end) + line.model?.materials = [createGreenMaterial()] + boxGroup.addChild(line) + } + + boxGroup.position = bounds.center + + return boxGroup + } + + private func createGreenMaterial() -> UnlitMaterial { + var material = UnlitMaterial() + material.color = .init(tint: .systemGreen) + return material + } +} +``` + +### 5.3 Update RoomViewerViewController + +**File**: `Envision/Screens/MainTabs/Rooms/furniture+room/RoomViewerViewController.swift` + +```swift +import UIKit +import RealityKit + +// Add properties +private var measurementManager = MeasurementManager.shared +private var measurementVisualizer = MeasurementVisualizer() +private var measurementMode: MeasurementMode = .inactive +private var firstMeasurementPoint: SIMD3? +private var measurementControlPanel: MeasurementControlPanel! +private var liveMeasurementLine: ModelEntity? +private var measurementEntities: [UUID: [ModelEntity]] = [:] // Track entities per measurement + +enum MeasurementMode { + case inactive + case pointToPoint + case objectDimensions +} + +// MARK: - Setup Measurement UI + +override func viewDidLoad() { + super.viewDidLoad() + // ... existing setup code ... + + setupMeasurementControls() + setupMeasurementGestures() + observeMeasurementNotifications() +} + +private func setupMeasurementControls() { + measurementControlPanel = MeasurementControlPanel() + measurementControlPanel.translatesAutoresizingMaskIntoConstraints = false + measurementControlPanel.delegate = self + view.addSubview(measurementControlPanel) + + NSLayoutConstraint.activate([ + measurementControlPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + measurementControlPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + measurementControlPanel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + measurementControlPanel.heightAnchor.constraint(equalToConstant: 100) + ]) +} + +// MARK: - Measurement Gestures + +private func setupMeasurementGestures() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleMeasurementTap(_:))) + arView.addGestureRecognizer(tapGesture) +} + +@objc private func handleMeasurementTap(_ gesture: UITapGestureRecognizer) { + guard measurementMode != .inactive else { return } + + let location = gesture.location(in: arView) + + switch measurementMode { + case .pointToPoint: + handlePointToPointTap(at: location) + case .objectDimensions: + handleObjectDimensionsTap(at: location) + case .inactive: + break + } +} + +private func handlePointToPointTap(at location: CGPoint) { + // Raycast to find 3D position + guard let raycastResult = arView.raycast(from: location, allowing: .estimatedPlane, alignment: .any).first else { + print("⚠️ No surface found at tap location") + return + } + + let worldPosition = SIMD3( + raycastResult.worldTransform.columns.3.x, + raycastResult.worldTransform.columns.3.y, + raycastResult.worldTransform.columns.3.z + ) + + if firstMeasurementPoint == nil { + // First point + firstMeasurementPoint = worldPosition + + // Create point indicator + let pointEntity = measurementVisualizer.createPointIndicator(at: worldPosition) + arView.scene.addAnchor(pointEntity) + + print("📍 First measurement point placed") + } else { + // Second point - complete measurement + guard let startPoint = firstMeasurementPoint else { return } + + // Create measurement + let measurement = Measurement( + type: .pointToPoint, + startPoint: startPoint, + endPoint: worldPosition, + label: "Distance" + ) + + // Create visual elements + let lineEntity = measurementVisualizer.createLine(from: startPoint, to: worldPosition) + let midpoint = (startPoint + worldPosition) / 2 + let labelText = measurementManager.formatDistance(measurement.distance) + let labelEntity = measurementVisualizer.createLabel(text: labelText, at: midpoint) + let endPointEntity = measurementVisualizer.createPointIndicator(at: worldPosition) + + // Add to scene + arView.scene.addAnchor(lineEntity) + arView.scene.addAnchor(labelEntity) + arView.scene.addAnchor(endPointEntity) + + // Store entities + measurementEntities[measurement.id] = [lineEntity, labelEntity, endPointEntity] + + // Add to manager + measurementManager.addMeasurement(measurement) + + // Reset for next measurement + firstMeasurementPoint = nil + removeLiveMeasurementLine() + + print("✅ Measurement complete: \(labelText)") + } +} + +private func handleObjectDimensionsTap(at location: CGPoint) { + // Cast ray to find furniture entity + guard let entity = arView.entity(at: location) as? ModelEntity else { + print("⚠️ No object found at tap location") + return + } + + // Get bounding box + let boundingBox = measurementVisualizer.createBoundingBox(for: entity) + arView.scene.addAnchor(boundingBox) + + // Get dimensions + let bounds = entity.visualBounds(relativeTo: nil) + let size = bounds.extents + + // Create dimension labels + let width = MeasurementManager.shared.formatDistance(size.x) + let height = MeasurementManager.shared.formatDistance(size.y) + let depth = MeasurementManager.shared.formatDistance(size.z) + + let dimensionText = "W: \(width) × H: \(height) × D: \(depth)" + let labelEntity = measurementVisualizer.createLabel(text: dimensionText, at: bounds.center + SIMD3(0, size.y/2 + 0.1, 0)) + arView.scene.addAnchor(labelEntity) + + print("📐 Object dimensions: \(dimensionText)") +} + +// MARK: - Live Measurement Line (while dragging) + +override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + + guard measurementMode == .pointToPoint, + let firstPoint = firstMeasurementPoint, + let touch = touches.first else { + return + } + + let location = touch.location(in: arView) + + guard let raycastResult = arView.raycast(from: location, allowing: .estimatedPlane, alignment: .any).first else { + return + } + + let currentPosition = SIMD3( + raycastResult.worldTransform.columns.3.x, + raycastResult.worldTransform.columns.3.y, + raycastResult.worldTransform.columns.3.z + ) + + // Update or create live line + if let liveLine = liveMeasurementLine { + liveLine.removeFromParent() + } + + let lineEntity = measurementVisualizer.createLine(from: firstPoint, to: currentPosition) + arView.scene.addAnchor(lineEntity) + liveMeasurementLine = lineEntity + + // Update live distance label + let distance = simd_distance(firstPoint, currentPosition) + let labelText = measurementManager.formatDistance(distance) + print("📏 Live measurement: \(labelText)") +} + +private func removeLiveMeasurementLine() { + liveMeasurementLine?.removeFromParent() + liveMeasurementLine = nil +} + +// MARK: - Notification Observers + +private func observeMeasurementNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(handleMeasurementsCleared), name: .measurementsCleared, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleMeasurementRemoved(_:)), name: .measurementRemoved, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleUnitChanged), name: .measurementUnitChanged, object: nil) +} + +@objc private func handleMeasurementsCleared() { + // Remove all measurement entities from scene + for (_, entities) in measurementEntities { + entities.forEach { $0.removeFromParent() } + } + measurementEntities.removeAll() + + // Remove bounding boxes + arView.scene.anchors.forEach { anchor in + if let entity = anchor as? ModelEntity, entity.name == "boundingBox" { + entity.removeFromParent() + } + } + + print("🗑️ All measurements cleared") +} + +@objc private func handleMeasurementRemoved(_ notification: Notification) { + guard let id = notification.object as? UUID else { return } + + // Remove entities for this measurement + measurementEntities[id]?.forEach { $0.removeFromParent() } + measurementEntities.removeValue(forKey: id) + + print("🗑️ Measurement removed: \(id)") +} + +@objc private func handleUnitChanged() { + // Refresh all measurement labels + for (id, entities) in measurementEntities { + guard let measurement = measurementManager.measurements.first(where: { $0.id == id }) else { continue } + + // Find and update label entity + if let labelEntity = entities.first(where: { $0.name == "measurementLabel" }) { + labelEntity.removeFromParent() + + let midpoint = (measurement.startPoint + measurement.endPoint) / 2 + let newLabelText = measurementManager.formatDistance(measurement.distance) + let newLabel = measurementVisualizer.createLabel(text: newLabelText, at: midpoint) + arView.scene.addAnchor(newLabel) + + // Update stored entity + if let index = entities.firstIndex(where: { $0.name == "measurementLabel" }) { + measurementEntities[id]?[index] = newLabel + } + } + } +} +``` + +--- + +## 6. ARKit Implementation (Alternative) + +If not using RealityKit, use ARKit with SceneKit for measurement visualization: + +**File**: `Envision/Managers/ARMeasurementHelper.swift` + +```swift +import ARKit +import SceneKit + +class ARMeasurementHelper { + + func createLineNode(from start: SCNVector3, to end: SCNVector3) -> SCNNode { + let lineGeometry = SCNCylinder(radius: 0.002, height: CGFloat(distance(start, end))) + lineGeometry.firstMaterial?.diffuse.contents = UIColor.systemBlue + + let lineNode = SCNNode(geometry: lineGeometry) + lineNode.position = midpoint(start, end) + + // Rotate to align with direction + lineNode.look(at: end, up: SCNVector3(0, 1, 0), localFront: SCNVector3(0, 1, 0)) + + return lineNode + } + + func createTextNode(text: String, at position: SCNVector3) -> SCNNode { + let textGeometry = SCNText(string: text, extrusionDepth: 1.0) + textGeometry.font = UIFont.systemFont(ofSize: 10) + textGeometry.firstMaterial?.diffuse.contents = UIColor.white + + let textNode = SCNNode(geometry: textGeometry) + textNode.position = position + textNode.scale = SCNVector3(0.005, 0.005, 0.005) + + // Billboard constraint (always face camera) + let constraint = SCNBillboardConstraint() + constraint.freeAxes = [.Y] + textNode.constraints = [constraint] + + return textNode + } + + private func distance(_ a: SCNVector3, _ b: SCNVector3) -> Float { + let dx = a.x - b.x + let dy = a.y - b.y + let dz = a.z - b.z + return sqrt(dx*dx + dy*dy + dz*dz) + } + + private func midpoint(_ a: SCNVector3, _ b: SCNVector3) -> SCNVector3 { + return SCNVector3((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2) + } +} +``` + +--- + +## 7. Measurement Precision + +### 7.1 Accuracy Factors + +**LiDAR-based measurements** (iPhone 12 Pro+): +- Typical accuracy: ±1-2cm at 5m distance +- Best case: ±0.5cm at 1m distance + +**Visual SLAM** (non-LiDAR devices): +- Typical accuracy: ±5-10cm +- Depends on lighting, texture, movement + +### 7.2 Improve Accuracy + +```swift +// Use LiDAR when available +func performHighAccuracyRaycast(from point: CGPoint) -> SIMD3? { + // Prefer LiDAR depth data + if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) { + // Use scene reconstruction raycast + if let result = arView.raycast(from: point, allowing: .estimatedPlane, alignment: .any).first { + return SIMD3( + result.worldTransform.columns.3.x, + result.worldTransform.columns.3.y, + result.worldTransform.columns.3.z + ) + } + } + + // Fallback to visual SLAM + if let result = arView.hitTest(point, types: [.featurePoint, .estimatedHorizontalPlane]).first { + return SIMD3( + result.worldTransform.columns.3.x, + result.worldTransform.columns.3.y, + result.worldTransform.columns.3.z + ) + } + + return nil +} +``` + +### 7.3 Confidence Indicators + +```swift +func getMeasurementConfidence(measurement: Measurement) -> String { + let distance = measurement.distance + + if distance < 1.0 { + return "High confidence (±0.5cm)" + } else if distance < 5.0 { + return "Medium confidence (±2cm)" + } else { + return "Low confidence (±5cm)" + } +} +``` + +--- + +## 8. Code Implementation + +### 8.1 Create MeasurementControlPanel (UI) + +**File**: `Envision/Components/MeasurementControlPanel.swift` + +```swift +import UIKit + +protocol MeasurementControlPanelDelegate: AnyObject { + func didTapMeasureButton() + func didTapDimensionsButton() + func didTapInfoButton() + func didTapClearAllButton() + func didTapToggleUnits() + func didTapExportButton() +} + +final class MeasurementControlPanel: UIView { + + weak var delegate: MeasurementControlPanelDelegate? + + // MARK: - UI Elements + + private let stackView = UIStackView() + private let measureButton = UIButton(type: .system) + private let dimensionsButton = UIButton(type: .system) + private let infoButton = UIButton(type: .system) + private let clearAllButton = UIButton(type: .system) + private let toggleUnitsButton = UIButton(type: .system) + private let exportButton = UIButton(type: .system) + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = UIColor.systemBackground.withAlphaComponent(0.95) + layer.cornerRadius = 16 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.1 + layer.shadowOffset = CGSize(width: 0, height: 2) + layer.shadowRadius = 8 + + // Stack view + stackView.axis = .vertical + stackView.spacing = 12 + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + + // Top row + let topRow = UIStackView() + topRow.axis = .horizontal + topRow.spacing = 8 + topRow.distribution = .fillEqually + + configureButton(measureButton, title: "Measure", icon: "ruler", action: #selector(measureTapped)) + configureButton(dimensionsButton, title: "Dimensions", icon: "square.3.layers.3d", action: #selector(dimensionsTapped)) + configureButton(infoButton, title: "Info", icon: "info.circle", action: #selector(infoTapped)) + + topRow.addArrangedSubview(measureButton) + topRow.addArrangedSubview(dimensionsButton) + topRow.addArrangedSubview(infoButton) + + // Bottom row + let bottomRow = UIStackView() + bottomRow.axis = .horizontal + bottomRow.spacing = 8 + bottomRow.distribution = .fillEqually + + configureButton(clearAllButton, title: "Clear All", icon: "trash", action: #selector(clearAllTapped)) + configureButton(toggleUnitsButton, title: "m ↔ ft", icon: "arrow.left.arrow.right", action: #selector(toggleUnitsTapped)) + configureButton(exportButton, title: "Export", icon: "square.and.arrow.up", action: #selector(exportTapped)) + + bottomRow.addArrangedSubview(clearAllButton) + bottomRow.addArrangedSubview(toggleUnitsButton) + bottomRow.addArrangedSubview(exportButton) + + stackView.addArrangedSubview(topRow) + stackView.addArrangedSubview(bottomRow) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 12), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12) + ]) + } + + private func configureButton(_ button: UIButton, title: String, icon: String, action: Selector) { + var config = UIButton.Configuration.filled() + config.baseBackgroundColor = .systemBlue + config.baseForegroundColor = .white + config.cornerStyle = .medium + config.image = UIImage(systemName: icon) + config.imagePlacement = .top + config.imagePadding = 4 + config.title = title + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .systemFont(ofSize: 12, weight: .medium) + return outgoing + } + + button.configuration = config + button.addTarget(self, action: action, for: .touchUpInside) + } + + // MARK: - Actions + + @objc private func measureTapped() { + delegate?.didTapMeasureButton() + highlightButton(measureButton) + } + + @objc private func dimensionsTapped() { + delegate?.didTapDimensionsButton() + highlightButton(dimensionsButton) + } + + @objc private func infoTapped() { + delegate?.didTapInfoButton() + } + + @objc private func clearAllTapped() { + delegate?.didTapClearAllButton() + } + + @objc private func toggleUnitsTapped() { + delegate?.didTapToggleUnits() + + // Update button label + let currentUnit = MeasurementManager.shared.currentUnit + let newTitle = currentUnit == .metric ? "ft ↔ m" : "m ↔ ft" + toggleUnitsButton.configuration?.title = newTitle + } + + @objc private func exportTapped() { + delegate?.didTapExportButton() + } + + private func highlightButton(_ button: UIButton) { + // Visual feedback for active measurement mode + UIView.animate(withDuration: 0.2) { + button.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) + } completion: { _ in + UIView.animate(withDuration: 0.2) { + button.transform = .identity + } + } + } +} +``` + +### 8.2 Implement Delegate in RoomViewerViewController + +```swift +extension RoomViewerViewController: MeasurementControlPanelDelegate { + + func didTapMeasureButton() { + measurementMode = .pointToPoint + measurementManager.activateMeasurementMode() + print("📏 Measurement mode activated") + + // Show instruction overlay + showMeasurementInstruction("Tap two points to measure distance") + } + + func didTapDimensionsButton() { + measurementMode = .objectDimensions + measurementManager.activateMeasurementMode() + print("📐 Object dimensions mode activated") + + showMeasurementInstruction("Tap on furniture to see dimensions") + } + + func didTapInfoButton() { + measurementMode = .inactive + measurementManager.deactivateMeasurementMode() + + // Show room info panel + showRoomInfoPanel() + } + + func didTapClearAllButton() { + let alert = UIAlertController(title: "Clear All Measurements", message: "This will remove all measurement lines and labels.", preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in + MeasurementManager.shared.clearAllMeasurements() + }) + + present(alert, animated: true) + } + + func didTapToggleUnits() { + let currentUnit = MeasurementManager.shared.currentUnit + MeasurementManager.shared.currentUnit = (currentUnit == .metric) ? .imperial : .metric + + print("🔄 Units toggled to: \(MeasurementManager.shared.currentUnit)") + } + + func didTapExportButton() { + let text = MeasurementManager.shared.exportMeasurementsAsText() + + let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil) + present(activityVC, animated: true) + } + + private func showMeasurementInstruction(_ text: String) { + // Show temporary toast/banner with instruction + let label = UILabel() + label.text = text + label.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.9) + label.textColor = .white + label.textAlignment = .center + label.font = .systemFont(ofSize: 14, weight: .medium) + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + label.heightAnchor.constraint(equalToConstant: 44) + ]) + + label.layer.cornerRadius = 8 + label.clipsToBounds = true + label.alpha = 0 + + UIView.animate(withDuration: 0.3) { + label.alpha = 1 + } completion: { _ in + UIView.animate(withDuration: 0.3, delay: 2.0) { + label.alpha = 0 + } completion: { _ in + label.removeFromSuperview() + } + } + } + + private func showRoomInfoPanel() { + // Show panel with room dimensions from metadata + guard let roomMetadata = currentRoomMetadata else { + print("⚠️ No room metadata available") + return + } + + let alert = UIAlertController(title: roomName, message: nil, preferredStyle: .alert) + + var infoText = "" + + if let dimensions = roomMetadata.dimensions { + if let width = dimensions["width"] { + infoText += "Width: \(MeasurementManager.shared.formatDistance(Float(width)))\n" + } + if let length = dimensions["length"] { + infoText += "Length: \(MeasurementManager.shared.formatDistance(Float(length)))\n" + } + if let height = dimensions["height"] { + infoText += "Height: \(MeasurementManager.shared.formatDistance(Float(height)))\n" + } + + // Calculate area + if let width = dimensions["width"], let length = dimensions["length"] { + let area = Float(width * length) + infoText += "Floor Area: \(MeasurementManager.shared.formatArea(area))" + } + } else { + infoText = "No dimensions available for this room." + } + + alert.message = infoText + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + present(alert, animated: true) + } +} +``` + +--- + +## 9. Testing Plan + +### 9.1 Unit Tests + +```swift +import XCTest +@testable import Envision + +class MeasurementTests: XCTestCase { + + func testDistanceCalculation() { + let start = SIMD3(0, 0, 0) + let end = SIMD3(1, 0, 0) + + let measurement = Measurement(type: .pointToPoint, startPoint: start, endPoint: end) + + XCTAssertEqual(measurement.distance, 1.0, accuracy: 0.001) + } + + func testUnitConversion() { + let manager = MeasurementManager.shared + + // Test metric + manager.currentUnit = .metric + XCTAssertEqual(manager.formatDistance(1.5), "1.50 m") + XCTAssertEqual(manager.formatDistance(0.75), "75 cm") + + // Test imperial + manager.currentUnit = .imperial + let result = manager.formatDistance(1.0) // 1m = 3.28 feet = 3' 3.4" + XCTAssertTrue(result.contains("3'")) + } + + func testAreaCalculation() { + let manager = MeasurementManager.shared + + manager.currentUnit = .metric + XCTAssertEqual(manager.formatArea(20.0), "20.00 m²") + + manager.currentUnit = .imperial + let result = manager.formatArea(20.0) // 20 m² ≈ 215 ft² + XCTAssertTrue(result.contains("ft²")) + } +} +``` + +### 9.2 Integration Tests + +**Test Scenarios**: +1. Create point-to-point measurement → Verify line + label appear +2. Toggle units → Verify labels update +3. Clear all → Verify all entities removed +4. Measure object dimensions → Verify bounding box shows +5. Export measurements → Verify text contains all measurements + +### 9.3 Manual Testing Checklist + +- [ ] Place first point → Point indicator appears +- [ ] Drag to second point → Live line follows finger +- [ ] Release → Line + label persist +- [ ] Create multiple measurements → All visible simultaneously +- [ ] Toggle m ↔ ft → Labels update instantly +- [ ] Clear all → All measurements disappear +- [ ] Tap furniture → Bounding box shows +- [ ] Export → Share sheet appears with text +- [ ] Rotate view → Labels remain visible (billboarding works) +- [ ] Switch rooms → Old measurements don't carry over + +--- + +## 10. Enhancement Ideas + +### 10.1 Advanced Features + +**1. Snap to Grid**: +```swift +func snapToGrid(_ position: SIMD3, gridSize: Float = 0.1) -> SIMD3 { + return SIMD3( + round(position.x / gridSize) * gridSize, + round(position.y / gridSize) * gridSize, + round(position.z / gridSize) * gridSize + ) +} +``` + +**2. Angle Measurement**: +```swift +struct AngleMeasurement { + let point1: SIMD3 + let vertex: SIMD3 + let point2: SIMD3 + + var angle: Float { + let v1 = point1 - vertex + let v2 = point2 - vertex + let dot = simd_dot(simd_normalize(v1), simd_normalize(v2)) + return acos(dot) * (180.0 / .pi) // Degrees + } +} +``` + +**3. Area Measurement** (polygon): +```swift +func calculateArea(points: [SIMD3]) -> Float { + // Shoelace formula for polygon area + var area: Float = 0 + for i in 0.. Float { + let bounds = entity.visualBounds(relativeTo: nil) + let size = bounds.extents + return size.x * size.y * size.z +} +``` + +**5. Persistent Measurements** (save to room metadata): +```swift +extension RoomMetadata { + var measurements: [Measurement]? +} + +// Save +roomMetadata.measurements = MeasurementManager.shared.measurements + +// Load +if let savedMeasurements = roomMetadata.measurements { + savedMeasurements.forEach { recreateMeasurementVisuals($0) } +} +``` + +### 10.2 UI Enhancements + +**1. Measurement History Panel**: +``` +┌────────────────────────────┐ +│ Measurements (3) │ +│ ──────────────────────── │ +│ 1. Distance: 2.45 m │ +│ 2. Chair width: 0.65 m │ +│ 3. Table to wall: 1.20 m │ +└────────────────────────────┘ +``` + +**2. Visual Feedback**: +- Haptic feedback on tap (UIImpactFeedbackGenerator) +- Sound effect on measurement complete +- Pulse animation for point indicators + +**3. Accessibility**: +- VoiceOver support for measurements +- High contrast mode for lines/labels +- Dynamic Type for labels + +### 10.3 Performance Optimization + +**1. Entity Pooling**: +```swift +class MeasurementEntityPool { + private var linePool: [ModelEntity] = [] + private var labelPool: [ModelEntity] = [] + + func getLine() -> ModelEntity { + return linePool.popLast() ?? createNewLine() + } + + func returnLine(_ entity: ModelEntity) { + entity.isEnabled = false + linePool.append(entity) + } +} +``` + +**2. LOD (Level of Detail)**: +```swift +func updateMeasurementVisuals(cameraPosition: SIMD3) { + for (id, entities) in measurementEntities { + let distance = simd_distance(cameraPosition, entities[0].position) + + if distance > 10.0 { + // Far away - hide labels, show simplified lines + entities.forEach { $0.isEnabled = false } + } else { + // Close - show full detail + entities.forEach { $0.isEnabled = true } + } + } +} +``` + +--- + +## Appendix: Quick Start Guide + +### For Developers + +**Step 1**: Copy files to project +- `MeasurementManager.swift` → `Envision/Managers/` +- `MeasurementVisualizer.swift` → `Envision/Managers/` +- `MeasurementControlPanel.swift` → `Envision/Components/` + +**Step 2**: Update `RoomViewerViewController.swift` +- Add properties + setup methods +- Implement delegate +- Add gesture handlers + +**Step 3**: Test +- Build and run +- Navigate to room viewer +- Tap "Measure" → Tap two points +- Verify line + label appear + +**Step 4**: Customize +- Adjust colors in visualizer +- Change label font size +- Add more measurement types + +--- + +**Document Version**: 1.0 +**Last Updated**: January 21, 2026 +**Estimated Implementation Time**: 3-5 days +**Priority**: Medium-High + +--- + +*End of AR Measurement Tool Implementation Plan* diff --git a/COMPLETE_TECHNICAL_DOCUMENTATION.md b/COMPLETE_TECHNICAL_DOCUMENTATION.md new file mode 100644 index 0000000..92b9e12 --- /dev/null +++ b/COMPLETE_TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,1029 @@ +# EnVision - Complete Technical Documentation +*Last Updated: January 21, 2026* + +--- + +## Table of Contents +1. [Project Overview](#project-overview) +2. [Architecture](#architecture) +3. [Authentication Flow](#authentication-flow) +4. [Core Features](#core-features) +5. [Data Models](#data-models) +6. [File Structure](#file-structure) +7. [Third-Party Dependencies](#third-party-dependencies) +8. [Backend Design (Firebase)](#backend-design-firebase) +9. [Tips & Tour System (REMOVED)](#tips--tour-system-removed) +10. [Key Improvements Needed](#key-improvements-needed) + +--- + +## 1. Project Overview + +**EnVision** is an iOS 17+ AR/spatial computing app that allows users to: +- Scan rooms using **RoomPlan** (LiDAR) +- Capture furniture using **photogrammetry** (Object Capture) +- View and edit 3D models in AR +- Organize rooms and furniture with categories +- Manage user profiles with preferences + +**Tech Stack:** +- **Language**: Swift (UIKit programmatic, no Storyboards except LaunchScreen) +- **Frameworks**: ARKit, RealityKit, RoomPlan, AVFoundation, QuickLook +- **Persistence**: UserDefaults (currently), FileManager (3D models) +- **Planned Backend**: Firebase (Auth + Firestore + Storage) + +--- + +## 2. Architecture + +### 2.1 App Entry Flow +``` +SceneDelegate (willConnectTo) + ↓ +SplashViewController (logo animation ~2s) + ↓ +OnboardingController (UIPageViewController, 3 pages) + ↓ +LoginViewController + ↓ +MainTabBarController (3 tabs) +``` + +**Note**: Currently no "auto-skip login" logic. Every launch goes through Splash → Onboarding → Login. + +### 2.2 Navigation Structure +``` +MainTabBarController +├── Tab 0: My Rooms (UINavigationController) +│ └── MyRoomsViewController +│ ├── RoomPlanScannerViewController (LiDAR scan) +│ ├── RoomPreviewViewController (save scanned room) +│ ├── RoomViewerViewController (3D viewer + furniture placement) +│ └── RoomEditVC (edit room metadata) +├── Tab 1: My Furniture (UINavigationController) +│ └── ScanFurnitureViewController +│ ├── ObjectScanViewController (auto-capture photogrammetry) +│ ├── ObjectCapturePreviewController (process images) +│ ├── CreateModelViewController (manual photo selection) +│ └── ViewModelsViewController (browse USDZ files) +└── Tab 2: Profile (UINavigationController) + └── ProfileViewController + ├── EditProfileViewController + ├── TipsLibraryViewController (static tips list) + ├── SettingsViewController + └── ThemeViewController +``` + +### 2.3 Key Design Patterns +- **Programmatic UIKit**: No Interface Builder (except LaunchScreen.storyboard) +- **Singleton Managers**: `UserManager`, `SaveManager`, `TourManager`, `MetadataManager` +- **Delegate Pattern**: Used for camera capture, search, collection view +- **File-based Persistence**: USDZ models stored in Documents directory +- **JSON Metadata**: Room/furniture metadata stored as JSON files + +--- + +## 3. Authentication Flow + +### 3.1 Current Implementation (Local Only) + +#### Files Involved: +- `Envision/Screens/Onboarding/LoginViewController.swift` +- `Envision/Screens/Onboarding/SignupViewController.swift` +- `Envision/Screens/Onboarding/ForgotPasswordViewController.swift` +- `Envision/Extensions/UserManager.swift` +- `Envision/Extensions/UserModel.swift` +- `Envision/SceneDelegate.swift` + +#### Login Flow: +1. **UI**: `LoginViewController` + - Email + Password fields (custom `ModernTextField`) + - "Continue" button → `handleLogin()` + - "Create Account" → pushes `SignupViewController` + - "Forgot Password" → pushes `ForgotPasswordViewController` + +2. **Validation**: + - Non-empty fields + - Valid email format (`String.isValidEmail` in `Extensions.swift`) + +3. **Auth Logic** (currently simulated): + ```swift + UserManager.shared.login(email: String, password: String) { success in + if success { + SceneDelegate.shared?.switchToMainApp() + } + } + ``` + +4. **Current Behavior**: + - If a user exists in UserDefaults with matching email → success + - Else creates a new user and logs in (no password verification) + +#### Signup Flow: +1. **UI**: `SignupViewController` + - Name, Email, Password, Confirm Password + - Validates strong password (`String.isStrongPassword`) + +2. **Auth Logic**: + ```swift + UserManager.shared.signup(name:email:password:) { success in + if success { + SceneDelegate.shared?.switchToMainApp() + } + } + ``` + +3. **Current Behavior**: + - Creates `UserModel` with provided data + - Stores in UserDefaults as JSON (`"currentUser"` key) + - Switches to `MainTabBarController` + +#### Forgot Password Flow: +1. **UI**: `ForgotPasswordViewController` + - Email field only + - "Send Reset Link" button → `handleReset()` + +2. **Current Behavior**: + - Validates email + - Shows alert "Check your email" + - **No actual backend call** (TODO comment in code) + +### 3.2 Session Management + +**Current**: +- `UserManager.shared.currentUser` stored in `UserDefaults` +- `UserManager.shared.isLoggedIn` checks if `currentUser != nil` +- No auto-login on app launch + +**Logout**: +- `UserManager.shared.logout()` clears UserDefaults +- `SceneDelegate.shared?.switchToLogin()` resets root to login + +--- + +## 4. Core Features + +### 4.1 Room Scanning (RoomPlan + LiDAR) + +#### Files: +- `Envision/Screens/MainTabs/Rooms/MyRoomsViewController.swift` (main library) +- `Envision/Screens/MainTabs/Rooms/RoomPlanScan/RoomPlanScannerViewController.swift` (LiDAR scan) +- `Envision/Screens/MainTabs/Rooms/RoomPlanScan/RoomPreviewViewController.swift` (save scan) +- `Envision/Screens/MainTabs/Rooms/RoomModel.swift` (data model) +- `Envision/Screens/MainTabs/Rooms/MetadataManager.swift` (JSON persistence) + +#### Flow: +1. **Scan Initiation**: `MyRoomsViewController` → "+" button → `RoomPlanScannerViewController` +2. **LiDAR Capture**: User walks around room, RoomPlan builds 3D mesh +3. **Preview**: `RoomPreviewViewController` shows captured room +4. **Save**: + - USDZ file saved to `Documents/roomPlan/{UUID}.usdz` + - Metadata saved to `Documents/roomPlan/rooms_metadata.json` + - Thumbnail generated using QuickLook + +#### Data Model: +```swift +struct RoomModel { + let id: UUID + let name: String + var category: RoomCategory + let createdAt: Date + let usdzFilename: String + var thumbnailPath: String? +} + +struct RoomMetadata { + var name: String + var category: String + var createdAt: String + var dimensions: [String: Double]? + var notes: String? +} +``` + +#### Categories: +- Living Room, Bedroom, Kitchen, Bathroom, Office, Dining Room, Garage, Outdoor, Other + +### 4.2 Furniture Capture (Object Capture / Photogrammetry) + +#### Files: +- `Envision/Screens/MainTabs/furniture/ScanFurnitureViewController.swift` (library) +- `Envision/Screens/MainTabs/furniture/Object Capture/ObjectScanViewController.swift` (auto-capture) +- `Envision/Screens/MainTabs/furniture/Object Capture/ObjectCapturePreviewController.swift` (process) +- `Envision/Screens/MainTabs/furniture/CreateModel/CreateModelViewController.swift` (manual photos) +- `Envision/Screens/MainTabs/furniture/FurnitureCategory.swift` + +#### Flow: +1. **Scan Initiation**: `ScanFurnitureViewController` → Scan menu +2. **Options**: + - **Automatic Object Capture**: `ObjectScanViewController` + - Auto-captures photos every 0.5s while user walks around object + - Shows counter, quality indicator, guidance + - Minimum 20 photos (recommended 50+) + - **Create From Photos**: `CreateModelViewController` + - User manually selects 20-100 photos from library + - Validates photo count and quality + +3. **Processing**: `ObjectCapturePreviewController` + - Uses `PhotogrammetrySession` (iOS 17+) + - Generates USDZ model + - Saves to `Documents/furniture/{UUID}.usdz` + +4. **Storage**: + - USDZ files in `Documents/furniture/` + - Thumbnails cached in-memory + +#### Categories: +- Seating, Tables, Storage, Beds, Lighting, Decor, Kitchen, Outdoor, Office, Electronics, Other + +### 4.3 3D Viewing & AR Placement + +#### Files: +- `Envision/Screens/MainTabs/Rooms/furniture+room/RoomViewerViewController.swift` +- `Envision/Screens/MainTabs/Rooms/furniture+room/FurniturePicker.swift` +- `Envision/Screens/MainTabs/Rooms/furniture+room/FurnitureControlPanel.swift` +- `Envision/Screens/MainTabs/Rooms/furniture+room/OrbitJoystick.swift` + +#### Features: +- **RealityKit-based 3D viewer** +- **Furniture placement** via drag-and-drop +- **Transform controls**: Move, Rotate, Scale +- **Orbit camera** with joystick +- **Save/Load** placed furniture positions +- **AR Preview** (launches QuickLook AR) + +### 4.4 Profile & Settings + +#### Files: +- `Envision/Screens/MainTabs/profile/ProfileViewController.swift` +- `Envision/Screens/MainTabs/profile/EditProfileViewController.swift` +- `Envision/Screens/MainTabs/profile/SubScreens/SettingsViewController.swift` +- `Envision/Screens/MainTabs/profile/SubScreens/ThemeViewController.swift` +- `Envision/Screens/MainTabs/profile/SubScreens/TipsLibraryViewController.swift` + +#### Features: +- **Edit Profile**: Name, Email, Bio, Profile Picture +- **Preferences**: Notifications, Scan Reminders, New Features +- **Theme**: Light, Dark, System +- **Tips & Tutorials**: Static list of all tips (not affected by TipKit removal) +- **App Tour Reset**: Resets tour state in `TourManager` +- **Logout**: Clears session and returns to login + +--- + +## 5. Data Models + +### 5.1 User Model +```swift +struct UserModel: Codable { + let id: String + var name: String + var email: String + var bio: String? + var profileImagePath: String? + let createdAt: Date + var preferences: UserPreferences +} + +struct UserPreferences: Codable { + var notificationsEnabled: Bool = true + var scanReminders: Bool = true + var newFeatureAlerts: Bool = true + var theme: Int = 0 // 0: System, 1: Light, 2: Dark +} +``` + +**Persistence**: UserDefaults (`"currentUser"` key, JSON-encoded) + +### 5.2 Room Model +```swift +struct RoomModel { + let id: UUID + let name: String + var category: RoomCategory + let createdAt: Date + let usdzFilename: String + var thumbnailPath: String? +} + +struct RoomMetadata: Codable { + var name: String + var category: String + var createdAt: String + var dimensions: [String: Double]? + var notes: String? +} + +struct RoomsMetadata: Codable { + let version: String + var rooms: [String: RoomMetadata] // Key = filename +} +``` + +**Persistence**: +- USDZ files: `Documents/roomPlan/{filename}.usdz` +- Metadata: `Documents/roomPlan/rooms_metadata.json` +- Thumbnails: `Documents/roomPlan/thumbnails/{filename}.jpg` + +### 5.3 Furniture Model +```swift +// No explicit struct - just file URLs +// Category inference from filename or UserDefaults + +enum FurnitureCategory: String, CaseIterable { + case seating, tables, storage, beds, lighting, + decor, kitchen, outdoor, office, electronics, other + + var icon: String { /* SF Symbol */ } + var color: UIColor { /* Category color */ } +} +``` + +**Persistence**: +- USDZ files: `Documents/furniture/{filename}.usdz` +- Category: `UserDefaults` (`"furniture_category_{filename}"`) +- Thumbnails: In-memory cache (`NSCache`) + +--- + +## 6. File Structure + +### 6.1 Project Organization +``` +EnVision/ +├── Envision/ +│ ├── AppDelegate.swift # App lifecycle, theme setup +│ ├── SceneDelegate.swift # Window/scene management, root switching +│ ├── MainTabBarController.swift # 3-tab container +│ ├── Info.plist # App config (camera, photo library, ARKit) +│ │ +│ ├── Assets.xcassets/ # Images, icons, SF Symbols +│ ├── Base.lproj/ +│ │ └── LaunchScreen.storyboard # Only storyboard in project +│ │ +│ ├── 3D_Models/ # Sample USDZ files +│ │ ├── chair.usdz +│ │ ├── table.usdz +│ │ ├── hall.usdz +│ │ └── ios_room*.usdz +│ │ +│ ├── Components/ # Reusable UI components +│ │ ├── CustomTextField.swift +│ │ ├── PrimaryButton.swift +│ │ └── ModernTextField.swift +│ │ +│ ├── Extensions/ # Utilities & managers +│ │ ├── Extensions.swift # String validation, Date formatting +│ │ ├── UIColor+Hex.swift # Hex color support +│ │ ├── UIFont+AppFonts.swift # Custom fonts (if any) +│ │ ├── UIViewController+Transition.swift +│ │ ├── Entity+Visit.swift # RealityKit entity helpers +│ │ ├── UserManager.swift # Auth & user state +│ │ ├── UserModel.swift # User data model +│ │ └── SaveManager.swift # File I/O helpers +│ │ +│ ├── Managers/ +│ │ └── TourManager.swift # App tour state (deprecated) +│ │ +│ ├── Tips/ # TipKit (REMOVED, placeholder only) +│ │ ├── AppTips.swift # Empty placeholder +│ │ └── TipPresenter.swift # Empty placeholder +│ │ +│ └── Screens/ +│ ├── Onboarding/ # Login, Signup, Forgot Password +│ │ ├── SplashViewController.swift +│ │ ├── OnboardingController.swift +│ │ ├── OnboardingPage.swift +│ │ ├── LoginViewController.swift +│ │ ├── SignupViewController.swift +│ │ ├── ForgotPasswordViewController.swift +│ │ ├── ModernTextField.swift +│ │ └── SocialButton.swift +│ │ +│ └── MainTabs/ +│ ├── Rooms/ # Room scanning & management +│ │ ├── MyRoomsViewController.swift +│ │ ├── MyRoomsViewController+helpers.swift +│ │ ├── RoomModel.swift +│ │ ├── RoomCategory.swift +│ │ ├── RoomCell.swift +│ │ ├── MetadataManager.swift +│ │ ├── RoomPlanScan/ +│ │ │ ├── RoomPlanScannerViewController.swift +│ │ │ └── RoomPreviewViewController.swift +│ │ └── furniture+room/ +│ │ ├── RoomViewerViewController.swift +│ │ ├── RoomEditVC.swift +│ │ ├── RoomVisualizeVC.swift +│ │ ├── FurniturePicker.swift +│ │ ├── FurnitureControlPanel.swift +│ │ └── OrbitJoystick.swift +│ │ +│ ├── furniture/ # Furniture capture & library +│ │ ├── ScanFurnitureViewController.swift +│ │ ├── FurnitureCategory.swift +│ │ ├── FurnitureCell.swift +│ │ ├── Object Capture/ +│ │ │ ├── ObjectScanViewController.swift +│ │ │ ├── ObjectCapturePreviewController.swift +│ │ │ ├── ARMeshExporter.swift +│ │ │ ├── InstructionOverlay.swift +│ │ │ ├── FeedbackBubble.swift +│ │ │ └── ArrowGuideView.swift +│ │ ├── CreateModel/ +│ │ │ ├── CreateModelViewController.swift +│ │ │ └── CreateModelViewController2.swift +│ │ ├── ModelsFromFiles/ +│ │ │ ├── ViewModelsViewController.swift +│ │ │ └── USDZCell.swift +│ │ └── roomPlanColor/ +│ │ └── (Color customization - not fully implemented) +│ │ +│ └── profile/ # User profile & settings +│ ├── ProfileViewController.swift +│ ├── ProfileCell.swift +│ ├── EditProfileViewController.swift +│ └── SubScreens/ +│ ├── SettingsViewController.swift +│ ├── ThemeViewController.swift +│ ├── TipsLibraryViewController.swift +│ ├── AboutViewController.swift +│ ├── PrivacyViewController.swift +│ └── SupportViewController.swift +│ +└── Envision.xcodeproj/ +``` + +### 6.2 Documents Directory Structure (Runtime) +``` +Documents/ +├── roomPlan/ +│ ├── {uuid}.usdz # Room USDZ files +│ ├── rooms_metadata.json # All room metadata +│ └── thumbnails/ +│ └── {uuid}.jpg +│ +└── furniture/ + └── {uuid}.usdz # Furniture USDZ files +``` + +--- + +## 7. Third-Party Dependencies + +**Current**: None (uses only Apple frameworks) + +**Planned** (for Firebase backend): +- Firebase SDK (via Swift Package Manager) + - FirebaseAuth + - FirebaseFirestore + - FirebaseStorage + +--- + +## 8. Backend Design (Firebase) + +### 8.1 Recommended Firebase Products + +1. **Firebase Authentication** + - Email/Password authentication + - Password reset emails + - (Future) Sign in with Apple, Google + +2. **Cloud Firestore** + - User profiles + - Room metadata + - Furniture metadata + - Shared collections + +3. **Firebase Storage** + - Profile pictures + - Room USDZ files (optional, can stay local) + - Furniture USDZ files (optional) + - Thumbnails + +### 8.2 Firestore Data Model + +#### Collection: `users/{uid}` +```json +{ + "name": "John Doe", + "email": "john@example.com", + "bio": "AR enthusiast", + "profileImageURL": "gs://bucket/users/{uid}/profile.jpg", + "createdAt": "2026-01-21T10:00:00Z", + "preferences": { + "notificationsEnabled": true, + "scanReminders": true, + "newFeatureAlerts": true, + "theme": 0 + } +} +``` + +#### Collection: `users/{uid}/rooms/{roomId}` +```json +{ + "id": "uuid", + "name": "Living Room", + "category": "living", + "createdAt": "2026-01-21T10:30:00Z", + "usdzURL": "gs://bucket/users/{uid}/rooms/{roomId}.usdz", + "thumbnailURL": "gs://bucket/users/{uid}/rooms/{roomId}_thumb.jpg", + "dimensions": { + "width": 5.2, + "length": 4.8, + "height": 2.7 + }, + "notes": "Main living area" +} +``` + +#### Collection: `users/{uid}/furniture/{furnitureId}` +```json +{ + "id": "uuid", + "name": "Modern Chair", + "category": "seating", + "createdAt": "2026-01-21T11:00:00Z", + "usdzURL": "gs://bucket/users/{uid}/furniture/{furnitureId}.usdz", + "thumbnailURL": "gs://bucket/users/{uid}/furniture/{furnitureId}_thumb.jpg" +} +``` + +### 8.3 Implementation Plan + +#### Phase 1: Authentication +1. Add Firebase SDK via SPM +2. Configure `FirebaseApp` in `AppDelegate` +3. Replace `UserManager.login/signup` with Firebase Auth calls: + ```swift + Auth.auth().signIn(withEmail:password:) { result, error in + // Fetch user doc from Firestore + } + + Auth.auth().createUser(withEmail:password:) { result, error in + // Create user doc in Firestore + } + + Auth.auth().sendPasswordReset(withEmail:) { error in + // Show success alert + } + ``` +4. Add auto-login in `SplashViewController`: + ```swift + if Auth.auth().currentUser != nil { + switchToMainApp() + } else { + goToOnboarding() + } + ``` + +#### Phase 2: User Profile Sync +1. Create Firestore helper: `FirestoreManager.swift` +2. On login/signup: fetch/create user doc +3. On profile edit: update Firestore + local cache +4. Keep local cache for offline access + +#### Phase 3: Room & Furniture Sync +1. Upload USDZ to Firebase Storage (optional, bandwidth consideration) +2. Store metadata in Firestore +3. Sync on app launch / manual refresh +4. Show sync indicator in UI + +#### Phase 4: Security Rules +```javascript +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /users/{userId} { + allow read, write: if request.auth.uid == userId; + + match /rooms/{roomId} { + allow read, write: if request.auth.uid == userId; + } + + match /furniture/{furnitureId} { + allow read, write: if request.auth.uid == userId; + } + } + } +} +``` + +### 8.4 Migration Strategy +- Keep existing file-based storage as primary +- Add Firebase as sync/backup layer +- Implement "Export/Import" feature for USDZ files +- Show cloud sync status in UI + +--- + +## 9. Tips & Tour System (REMOVED) + +### 9.1 Previous Implementation (Now Removed) + +**What was removed**: +- `import TipKit` from all files +- `Tips.configure()` in `AppDelegate` +- `Tips.resetDatastore()` in `TourManager` +- All `TipView(...)` hosting in view controllers +- `AppTips.swift` definitions (25 tips) +- `TipPresenter.swift` SwiftUI hosting controller + +**Reason for removal**: +- SwiftUI `TipView` hosting in UIKit was causing layout issues (vertical text, overlapping UI, non-responsive buttons) +- TipKit requires iOS 17+ and was adding complexity without stable behavior + +### 9.2 What Still Exists (Unchanged) + +**Profile → Tips & Tutorials**: +- `TipsLibraryViewController.swift` remains fully functional +- Shows static list of all tips (hardcoded) +- User can browse tips anytime regardless of app state +- No TipKit dependency + +**TourManager**: +- `TourManager.swift` remains for tour state tracking +- Stores `hasCompletedTour`, `currentTourStep` in UserDefaults +- `resetTour()` clears tour state (no longer calls TipKit) +- Used by "Restart App Tour" button in Profile + +### 9.3 Future Recommendation: Pure UIKit Tips + +If tips need to be re-added, implement as **pure UIKit** (no SwiftUI): + +1. **Custom UIView subclass**: `TipBubbleView` + - Arrow pointer (CAShapeLayer) + - Title + message labels + - Action buttons (primary + dismiss) + - Auto-layout constraints + +2. **Presentation**: + - Add as subview to target view controller + - Position relative to anchor view (e.g., below nav bar, above button) + - Animate in/out with spring animations + +3. **State Management**: + - Keep `TourManager` for progression tracking + - Store "seen tips" in UserDefaults + - Rules-based showing (e.g., "show after first room scan") + +4. **Benefits**: + - Full control over layout + - No SwiftUI hosting issues + - Responsive touch handling + - Native UIKit feel + +--- + +## 10. Key Improvements Needed + +### Priority 1: Tips & Tour System (CRITICAL) + +**Problem**: Tips feature was removed due to instability. App has no onboarding guidance. + +**Recommendation**: Implement pure UIKit tips system (see section 9.3) + +**Implementation Checklist**: +- [ ] Create `TipBubbleView.swift` (UIView subclass) + - Arrow pointer with CAShapeLayer + - Title, message, primary button, dismiss button + - Constraint-based layout +- [ ] Create `TipCoordinator.swift` + - Manages tip lifecycle (show, dismiss, track) + - Rules engine (conditions for showing) + - Integrates with TourManager +- [ ] Define tip content (same as old AppTips.swift): + - Welcome tip (on first launch) + - My Rooms tips (scan, import, actions menu) + - Furniture tips (capture, quality) + - Profile tips (settings, customization) +- [ ] Add tip anchors to view controllers: + - `MyRoomsViewController`: below nav bar, above collection view + - `ScanFurnitureViewController`: below scan button + - `ProfileViewController`: above settings row +- [ ] Wire progression: + - Step 1: Welcome → My Rooms + - Step 2: First room scan → Furniture + - Step 3: First furniture capture → Profile + - Step 4: Complete tour +- [ ] Add "Skip Tour" option (respects user choice) +- [ ] Test on iPhone 14 Pro / 15 Pro (different screen sizes) +- [ ] Ensure tips don't block critical UI elements + +**Estimated Effort**: 2-3 days + +--- + +### Priority 2: Firebase Backend Integration + +**Problem**: Auth & data currently local-only (UserDefaults, FileManager). No sync, no multi-device. + +**Recommendation**: Implement Firebase (see section 8) + +**Implementation Checklist**: +- [ ] Add Firebase SDK via SPM +- [ ] Configure Firebase project (console.firebase.google.com) +- [ ] Add `GoogleService-Info.plist` to Xcode +- [ ] Configure `FirebaseApp` in `AppDelegate.application(_:didFinishLaunchingWithOptions:)` +- [ ] Rewrite `UserManager.login/signup/reset` with Firebase Auth +- [ ] Add auto-login in `SplashViewController` +- [ ] Create `FirestoreManager.swift` for Firestore operations +- [ ] Sync user profile on login/edit +- [ ] (Optional) Upload USDZ to Firebase Storage +- [ ] Add offline caching (Firestore has built-in cache) +- [ ] Implement security rules +- [ ] Add sync indicator UI (cloud icon in nav bar) +- [ ] Handle network errors gracefully + +**Estimated Effort**: 4-5 days + +--- + +### Priority 3: Auto-Login & Session Persistence + +**Problem**: User must log in every time app launches (no persistent session check). + +**Recommendation**: Check Firebase Auth state in `SplashViewController` + +**Implementation**: +```swift +// In SplashViewController.goNext() +if let user = Auth.auth().currentUser { + // User already logged in + SceneDelegate.shared?.switchToMainApp() +} else { + // Show onboarding + let onboarding = OnboardingController() + present(onboarding, animated: true) +} +``` + +**Implementation Checklist**: +- [ ] Add Firebase Auth state check in `SplashViewController` +- [ ] Keep onboarding for first-time users only +- [ ] Add "Show Onboarding Again" option in Settings +- [ ] Test logout → re-login flow +- [ ] Test app termination → relaunch (session should persist) + +**Estimated Effort**: 1 day + +--- + +### Priority 4: Error Handling & Loading States + +**Problem**: No consistent error handling. No loading indicators during async operations. + +**Recommendation**: Add error alerts + loading overlays + +**Implementation Checklist**: +- [ ] Create `ErrorAlertHelper.swift` (standard error alert factory) +- [ ] Create `LoadingOverlay.swift` (reusable loading view) +- [ ] Add error handling to: + - Login/Signup (network errors, invalid credentials) + - Room scanning (RoomPlan failure, no LiDAR) + - Furniture capture (insufficient photos, processing failure) + - File operations (disk full, permission denied) +- [ ] Add loading states to: + - Login/Signup (show spinner during auth) + - Room/Furniture processing (show progress %) + - File uploads (Firebase Storage) +- [ ] Add retry logic for network failures +- [ ] Log errors to console (or Firebase Crashlytics) + +**Estimated Effort**: 2 days + +--- + +### Priority 5: Thumbnail Generation Optimization + +**Problem**: Thumbnails generated synchronously on main thread (blocks UI). + +**Recommendation**: Move to background queue + cache + +**Implementation Checklist**: +- [ ] Use `QLThumbnailGenerator` with `.background` queue +- [ ] Cache thumbnails in Documents (persistent, not in-memory only) +- [ ] Show placeholder image while generating +- [ ] Regenerate thumbnails if USDZ changes +- [ ] Add "Clear Thumbnail Cache" option in Settings + +**Estimated Effort**: 1 day + +--- + +### Priority 6: Search & Filter UX + +**Problem**: Search is basic text matching. No advanced filters (date, size, category combination). + +**Recommendation**: Add filter chips + sort options + +**Implementation Checklist**: +- [ ] Add sort menu (Name A-Z, Date Created, Recently Modified) +- [ ] Add multi-select category filter (not just single) +- [ ] Add date range filter (Last 7 days, Last 30 days, Custom) +- [ ] Add size filter for rooms (Small, Medium, Large) +- [ ] Persist filter/sort state in UserDefaults +- [ ] Show "Clear Filters" button when active + +**Estimated Effort**: 2 days + +--- + +### Priority 7: AR Placement Improvements + +**Problem**: Furniture placement in `RoomViewerViewController` is basic. No snap-to-grid, no collision detection. + +**Recommendation**: Add placement helpers + +**Implementation Checklist**: +- [ ] Add snap-to-grid (0.1m increments) +- [ ] Add collision detection (furniture can't overlap) +- [ ] Add "Align to Wall" button (snap to nearest room wall) +- [ ] Add measurement tool (show distance between furniture) +- [ ] Add undo/redo for transforms +- [ ] Add "Reset Position" button (return to original placement) +- [ ] Save/load furniture transforms in room metadata + +**Estimated Effort**: 3 days + +--- + +### Priority 8: Accessibility + +**Problem**: No VoiceOver support, no Dynamic Type support. + +**Recommendation**: Add accessibility labels + scale fonts + +**Implementation Checklist**: +- [ ] Add `.accessibilityLabel` to all interactive elements +- [ ] Add `.accessibilityHint` for non-obvious actions +- [ ] Support Dynamic Type (use `.preferredFont(forTextStyle:)`) +- [ ] Test with VoiceOver enabled +- [ ] Add high contrast mode support +- [ ] Add reduce motion support (disable fancy animations) +- [ ] Test with Accessibility Inspector + +**Estimated Effort**: 2 days + +--- + +### Priority 9: Localization + +**Problem**: All strings hardcoded in English. + +**Recommendation**: Extract strings to `Localizable.strings` + +**Implementation Checklist**: +- [ ] Create `Localizable.strings` (English) +- [ ] Replace all hardcoded strings with `NSLocalizedString` +- [ ] Add Spanish localization (es.lproj) +- [ ] Add French localization (fr.lproj) +- [ ] Test language switching +- [ ] Localize Info.plist strings (camera/photo permissions) + +**Estimated Effort**: 3 days + +--- + +### Priority 10: Unit & UI Tests + +**Problem**: No tests. No CI/CD. + +**Recommendation**: Add XCTest suite + +**Implementation Checklist**: +- [ ] Create `EnvisionTests` target +- [ ] Add unit tests for: + - `UserManager` (login/signup/logout) + - `MetadataManager` (load/save) + - `RoomModel` (init, category inference) + - String extensions (email/password validation) +- [ ] Create `EnvisionUITests` target +- [ ] Add UI tests for: + - Login flow (happy path + error cases) + - Signup flow + - Room scan flow (mock LiDAR) + - Furniture capture flow (mock camera) +- [ ] Set up GitHub Actions CI (run tests on PR) + +**Estimated Effort**: 4 days + +--- + +## Summary of Improvements (Prioritized) + +| Priority | Feature | Effort | Impact | Status | +|----------|---------|--------|--------|--------| +| **1** | **Tips & Tour System (UIKit)** | 2-3 days | **Critical** | ⚠️ **REMOVED** | +| **2** | **Firebase Backend** | 4-5 days | High | ❌ Not started | +| **3** | **Auto-Login** | 1 day | High | ❌ Not started | +| **4** | **Error Handling** | 2 days | High | ⚠️ Partial | +| **5** | **Thumbnail Optimization** | 1 day | Medium | ⚠️ Partial | +| **6** | **Search & Filter UX** | 2 days | Medium | ⚠️ Basic only | +| **7** | **AR Placement Helpers** | 3 days | Medium | ❌ Not started | +| **8** | **Accessibility** | 2 days | Medium | ❌ Not started | +| **9** | **Localization** | 3 days | Low | ❌ Not started | +| **10** | **Unit & UI Tests** | 4 days | Low | ❌ Not started | + +**Total Estimated Effort**: ~24-27 days + +--- + +## Appendix: Key Code Snippets + +### A1: UserManager.login (Current - Local Only) +```swift +// Envision/Extensions/UserManager.swift +func login(email: String, password: String, completion: @escaping (Bool) -> Void) { + // Simulated login + if let user = currentUser, user.email == email { + completion(true) + } else { + // Create new user (no password verification) + let newUser = UserModel( + id: UUID().uuidString, + name: email.components(separatedBy: "@").first ?? "User", + email: email, + createdAt: Date(), + preferences: UserPreferences() + ) + currentUser = newUser + completion(true) + } +} +``` + +### A2: SceneDelegate.switchToMainApp +```swift +// Envision/SceneDelegate.swift +func switchToMainApp() { + let mainVC = MainTabBarController() + + window?.rootViewController = mainVC + window?.makeKeyAndVisible() + + UIView.transition(with: window!, duration: 0.4, options: .transitionCrossDissolve) { + // Smooth fade transition + } +} +``` + +### A3: RoomPlanScanner (LiDAR) +```swift +// Envision/Screens/MainTabs/Rooms/RoomPlanScan/RoomPlanScannerViewController.swift +import RoomPlan + +class RoomPlanScannerViewController: UIViewController, RoomCaptureViewDelegate { + private var captureView: RoomCaptureView! + + override func viewDidLoad() { + super.viewDidLoad() + captureView = RoomCaptureView(frame: view.bounds) + captureView.captureSession.run(configuration: .init()) + captureView.delegate = self + view.addSubview(captureView) + } + + func captureView(_ view: RoomCaptureView, didEndWith data: CapturedRoom) { + // Convert to USDZ + let url = exportToUSDZ(data) + // Save and show preview + } +} +``` + +### A4: ObjectScanViewController (Photogrammetry) +```swift +// Envision/Screens/MainTabs/furniture/Object Capture/ObjectScanViewController.swift +class ObjectScanViewController: UIViewController { + private let session = AVCaptureSession() + private let photoOutput = AVCapturePhotoOutput() + private var images: [URL] = [] + + private func startAutoCapture() { + captureTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + self.takePhoto() + } + } + + @objc private func stopCapture() { + captureTimer?.invalidate() + // Process images with PhotogrammetrySession + let preview = ObjectCapturePreviewController(imagesFolder: tempFolderURL) + navigationController?.pushViewController(preview, animated: true) + } +} +``` + +--- + +**Document Version**: 1.0 +**Last Updated**: January 21, 2026 +**Author**: GitHub Copilot +**Project**: EnVision iOS App + +--- + +*End of Technical Documentation* diff --git a/Envision.xcodeproj/project.pbxproj b/Envision.xcodeproj/project.pbxproj index 6d53d0e..c8aa4cc 100644 --- a/Envision.xcodeproj/project.pbxproj +++ b/Envision.xcodeproj/project.pbxproj @@ -145,7 +145,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = R5PAJ8PLL5; + DEVELOPMENT_TEAM = 4PMY8MS8XC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Envision/Info.plist; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; @@ -161,7 +161,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 25.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.vinayakchandra.Envision; + PRODUCT_BUNDLE_IDENTIFIER = com.vinayakchandra.Envisionjhf; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -180,7 +180,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = R5PAJ8PLL5; + DEVELOPMENT_TEAM = 4PMY8MS8XC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Envision/Info.plist; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; @@ -196,7 +196,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 25.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.vinayakchandra.Envision; + PRODUCT_BUNDLE_IDENTIFIER = com.vinayakchandra.Envisionjhf; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/Envision.xcodeproj/project.xcworkspace/xcuserdata/user86.xcuserdatad/UserInterfaceState.xcuserstate b/Envision.xcodeproj/project.xcworkspace/xcuserdata/user86.xcuserdatad/UserInterfaceState.xcuserstate index d1f81f3..9a0919a 100644 Binary files a/Envision.xcodeproj/project.xcworkspace/xcuserdata/user86.xcuserdatad/UserInterfaceState.xcuserstate and b/Envision.xcodeproj/project.xcworkspace/xcuserdata/user86.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Envision/.DS_Store b/Envision/.DS_Store index 485e4de..10ffc22 100644 Binary files a/Envision/.DS_Store and b/Envision/.DS_Store differ diff --git a/Envision/AppDelegate.swift b/Envision/AppDelegate.swift index 8c01eca..08ab03f 100644 --- a/Envision/AppDelegate.swift +++ b/Envision/AppDelegate.swift @@ -7,39 +7,31 @@ import UIKit +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + + return true + } - import UIKit - - @main - class AppDelegate: UIResponder, UIApplicationDelegate { - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return true - } - - // Required for SceneDelegate lifecycle - func application(_ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions) -> UISceneConfiguration { - return UISceneConfiguration(name: "Default Configuration", - sessionRole: connectingSceneSession.role) - } - - + // Required for SceneDelegate lifecycle + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", + sessionRole: connectingSceneSession.role) + } // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { // Called when the user discards a scene session. // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - } - diff --git a/Envision/Assets.xcassets/.DS_Store b/Envision/Assets.xcassets/.DS_Store index dae4d1a..859dcf9 100644 Binary files a/Envision/Assets.xcassets/.DS_Store and b/Envision/Assets.xcassets/.DS_Store differ diff --git a/Envision/Assets.xcassets/custom.sofafill.viewfinder.symbolset/Contents.json b/Envision/Assets.xcassets/custom.sofafill.viewfinder.symbolset/Contents.json new file mode 100644 index 0000000..5206f33 --- /dev/null +++ b/Envision/Assets.xcassets/custom.sofafill.viewfinder.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "custom.sofafill.viewfinder.svg", + "idiom" : "universal" + } + ] +} diff --git a/Envision/Assets.xcassets/custom.sofafill.viewfinder.symbolset/custom.sofafill.viewfinder.svg b/Envision/Assets.xcassets/custom.sofafill.viewfinder.symbolset/custom.sofafill.viewfinder.svg new file mode 100644 index 0000000..48873ae --- /dev/null +++ b/Envision/Assets.xcassets/custom.sofafill.viewfinder.symbolset/custom.sofafill.viewfinder.svg @@ -0,0 +1,109 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.7.0 + Requires Xcode 17 or greater + Generated from viewfinder + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Envision/Extensions/Extensions.swift b/Envision/Extensions/Extensions.swift index 0ea6521..762552d 100644 --- a/Envision/Extensions/Extensions.swift +++ b/Envision/Extensions/Extensions.swift @@ -20,7 +20,7 @@ import UIKit // self.init(red: r, green: g, blue: b, alpha: a) // } //} - +//abhinav extension UIView { func applyGradientBackground(colors: [UIColor]) { let gradientLayer = CAGradientLayer() diff --git a/Envision/Extensions/SaveManager.swift b/Envision/Extensions/SaveManager.swift index 7d1f5c5..e699646 100644 --- a/Envision/Extensions/SaveManager.swift +++ b/Envision/Extensions/SaveManager.swift @@ -25,7 +25,7 @@ enum ModelType { case .furniture: return "Furniture" case .room: return "Room" } - } + } } struct ModelMetadata: Codable { @@ -143,7 +143,7 @@ final class SaveManager { completion(true) } } catch { - print("❌ Error deleting model: \(error)") + print("Error deleting model: \(error)") DispatchQueue.main.async { completion(false) } diff --git a/Envision/Extensions/UserManager.swift b/Envision/Extensions/UserManager.swift index b0f81ce..4d8dd88 100644 --- a/Envision/Extensions/UserManager.swift +++ b/Envision/Extensions/UserManager.swift @@ -112,7 +112,7 @@ final class UserManager { } return true } catch { - print("❌ Failed to save profile image: \(error)") + print(" Failed to save profile image: \(error)") return false } } diff --git a/Envision/MainTabBarController.swift b/Envision/MainTabBarController.swift index 7e656df..f1aa188 100644 --- a/Envision/MainTabBarController.swift +++ b/Envision/MainTabBarController.swift @@ -8,13 +8,18 @@ final class MainTabBarController: UITabBarController { setupLiquidGlassEffect() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Intentionally no global overlay tips here. + } + private func setupTabs() { // let home = UINavigationController(rootViewController: RoomsViewController()) let home = UINavigationController(rootViewController: MyRoomsViewController()) home.tabBarItem = UITabBarItem(title: "My Rooms", image: UIImage(systemName: "house"), selectedImage: UIImage(systemName: "house.fill")) let scan = UINavigationController(rootViewController: ScanFurnitureViewController()) - scan.tabBarItem = UITabBarItem(title: "My Furniture", image: UIImage(named: "sofa.viewfinder"), selectedImage: UIImage(named: "sofa.viewfinder")) + scan.tabBarItem = UITabBarItem(title: "My Furniture", image: UIImage(named: "sofa.viewfinder"), selectedImage: UIImage(named: "custom.sofafill.viewfinder")) // let shop = UINavigationController(rootViewController: ShopViewController()) // shop.tabBarItem = UITabBarItem(title: "Shop", image: UIImage(systemName: "bag"), selectedImage: UIImage(systemName: "bag.fill")) diff --git a/Envision/Managers/BackgroundModelProcessor.swift b/Envision/Managers/BackgroundModelProcessor.swift new file mode 100644 index 0000000..b54be57 --- /dev/null +++ b/Envision/Managers/BackgroundModelProcessor.swift @@ -0,0 +1,434 @@ +// +// BackgroundModelProcessor.swift +// Envision +// +// Background processing manager for 3D model generation +// Allows photogrammetry to continue even when user leaves the screen +// + +import Foundation +import RealityKit +import UIKit +import UserNotifications + +// MARK: - Processing Job Model +struct ProcessingJob: Codable, Identifiable { + let id: UUID + let imagesFolder: String + let outputFileName: String + let createdAt: Date + var status: ProcessingStatus + var progress: Float + var errorMessage: String? + + enum ProcessingStatus: String, Codable { + case queued + case processing + case completed + case failed + case cancelled + } +} + +// MARK: - Processing Delegate +protocol BackgroundModelProcessorDelegate: AnyObject { + func processingDidUpdateProgress(_ progress: Float, status: String) + func processingDidComplete(savedURL: URL) + func processingDidFail(error: String) +} + +// MARK: - Background Model Processor +final class BackgroundModelProcessor: @unchecked Sendable { + + static let shared = BackgroundModelProcessor() + + // MARK: - Properties + weak var delegate: BackgroundModelProcessorDelegate? + + private var currentJob: ProcessingJob? + private var currentSession: PhotogrammetrySession? + private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + + // Processing state (thread-safe access) + private let lock = NSLock() + private var _isProcessing = false + private var _currentProgress: Float = 0 + private var _currentStatus: String = "" + + var isProcessing: Bool { + lock.lock() + defer { lock.unlock() } + return _isProcessing + } + + var currentProgress: Float { + lock.lock() + defer { lock.unlock() } + return _currentProgress + } + + var currentStatus: String { + lock.lock() + defer { lock.unlock() } + return _currentStatus + } + + // Observers for UI updates when user returns + var onProgressUpdate: ((Float, String) -> Void)? + var onCompletion: ((URL) -> Void)? + var onError: ((String) -> Void)? + + private init() { + requestNotificationPermission() + } + + // MARK: - Notification Permission + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + print("📱 Notification permission: \(granted ? "granted" : "denied")") + } + } + + // MARK: - Start Processing + func startProcessing( + imagesFolder: URL, + detailLevel: PhotogrammetrySession.Request.Detail = .reduced, + completion: @escaping @Sendable (Result) -> Void + ) { + lock.lock() + guard !_isProcessing else { + lock.unlock() + completion(.failure(ProcessingError.alreadyProcessing)) + return + } + _isProcessing = true + _currentProgress = 0 + _currentStatus = "Preparing..." + lock.unlock() + + // Generate output filename + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + let timestamp = dateFormatter.string(from: Date()) + let outputFileName = "Furniture_\(timestamp).usdz" + + // Create job + currentJob = ProcessingJob( + id: UUID(), + imagesFolder: imagesFolder.path, + outputFileName: outputFileName, + createdAt: Date(), + status: .processing, + progress: 0, + errorMessage: nil + ) + + // Start background task for extended processing + beginBackgroundTask() + + // Start processing on background thread + processPhotogrammetry( + imagesFolder: imagesFolder, + outputFileName: outputFileName, + detailLevel: detailLevel, + completion: completion + ) + } + + // MARK: - Background Task Management + private func beginBackgroundTask() { + DispatchQueue.main.async { [weak self] in + self?.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "PhotogrammetryProcessing") { [weak self] in + self?.handleBackgroundTimeExpiring() + } + print(" Background task started") + } + } + + private func endBackgroundTask() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if self.backgroundTaskID != .invalid { + UIApplication.shared.endBackgroundTask(self.backgroundTaskID) + self.backgroundTaskID = .invalid + print(" Background task ended") + } + } + } + + private func handleBackgroundTimeExpiring() { + print(" Background time expiring...") + endBackgroundTask() + } + + // MARK: - Core Processing + private func processPhotogrammetry( + imagesFolder: URL, + outputFileName: String, + detailLevel: PhotogrammetrySession.Request.Detail, + completion: @escaping @Sendable (Result) -> Void + ) { + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(outputFileName) + + // Optimized configuration for speed + var config = PhotogrammetrySession.Configuration() + config.sampleOrdering = .sequential + config.featureSensitivity = .normal + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + let startTime = Date() + print(" Starting photogrammetry processing...") + print(" Input: \(imagesFolder.path)") + print(" Output: \(outputURL.path)") + print(" Detail level: \(detailLevel)") + + // Count images + let imageCount = (try? FileManager.default.contentsOfDirectory(at: imagesFolder, includingPropertiesForKeys: nil))? + .filter { ["jpg", "jpeg", "heic", "png"].contains($0.pathExtension.lowercased()) } + .count ?? 0 + print(" Processing \(imageCount) images") + + guard let session = try? PhotogrammetrySession( + input: imagesFolder, + configuration: config + ) else { + self.handleFailure(error: "Failed to create photogrammetry session", completion: completion) + return + } + + self.currentSession = session + + // Create the request with specified detail level + let request = PhotogrammetrySession.Request.modelFile( + url: outputURL, + detail: detailLevel + ) + + // Process outputs asynchronously + Task { + do { + for try await output in session.outputs { + switch output { + + case .processingComplete: + print(" Processing complete!") + + case .inputComplete: + print(" Input complete") + await MainActor.run { + self.updateProgress(0.1, status: "Analyzing images...") + } + + case .requestProgress(_, let fraction): + let mappedProgress = 0.1 + (Float(fraction) * 0.85) + + var status = "Processing..." + if fraction < 0.3 { + status = " Analyzing images..." + } else if fraction < 0.6 { + status = " Building 3D mesh..." + } else if fraction < 0.9 { + status = " Applying textures..." + } else { + status = " Finalizing model..." + } + + await MainActor.run { + self.updateProgress(mappedProgress, status: status) + } + + case .requestComplete(_, _): + let elapsed = Date().timeIntervalSince(startTime) + print(" Model generated in \(String(format: "%.1f", elapsed))s") + + await MainActor.run { + self.updateProgress(0.95, status: " Saving model...") + self.saveGeneratedModel(outputURL, completion: completion) + } + + case .requestError(_, let error): + print(" Request error: \(error)") + await MainActor.run { + self.handleFailure(error: error.localizedDescription, completion: completion) + } + + case .processingCancelled: + print(" Processing cancelled") + await MainActor.run { + self.handleFailure(error: "Processing was cancelled", completion: completion) + } + + case .invalidSample(let id, let reason): + print(" Invalid sample \(id): \(reason)") + + case .skippedSample(let id): + print(" Skipped sample: \(id)") + + case .automaticDownsampling: + print(" Automatic downsampling applied") + + default: + break + } + } + } catch { + print(" Task error: \(error)") + await MainActor.run { + self.handleFailure(error: error.localizedDescription, completion: completion) + } + } + } + + // Start processing + do { + try session.process(requests: [request]) + } catch { + print(" Process start error: \(error)") + self.handleFailure(error: error.localizedDescription, completion: completion) + } + } + } + + // MARK: - Progress Updates + private func updateProgress(_ progress: Float, status: String) { + lock.lock() + _currentProgress = progress + _currentStatus = status + currentJob?.progress = progress + lock.unlock() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.delegate?.processingDidUpdateProgress(progress, status: status) + self.onProgressUpdate?(progress, status) + print(" Progress: \(Int(progress * 100))% - \(status)") + } + } + + // MARK: - Save Model + private func saveGeneratedModel(_ url: URL, completion: @escaping @Sendable (Result) -> Void) { + SaveManager.shared.saveModel(from: url, type: .furniture, customName: nil) { [weak self] result in + guard let self = self else { return } + + self.endBackgroundTask() + + self.lock.lock() + self._isProcessing = false + self.lock.unlock() + + self.currentSession = nil + + switch result { + case .success(let savedURL): + self.currentJob?.status = .completed + self.updateProgress(1.0, status: " Complete!") + + DispatchQueue.main.async { + self.delegate?.processingDidComplete(savedURL: savedURL) + self.onCompletion?(savedURL) + } + + self.sendCompletionNotification(success: true) + completion(.success(savedURL)) + + case .failure(let error): + self.handleFailure(error: error.localizedDescription, completion: completion) + } + } + } + + // MARK: - Error Handling + private func handleFailure(error: String, completion: @escaping @Sendable (Result) -> Void) { + endBackgroundTask() + + lock.lock() + _isProcessing = false + lock.unlock() + + currentSession = nil + currentJob?.status = .failed + currentJob?.errorMessage = error + + DispatchQueue.main.async { [weak self] in + self?.delegate?.processingDidFail(error: error) + self?.onError?(error) + } + + sendCompletionNotification(success: false, errorMessage: error) + completion(.failure(ProcessingError.processingFailed(error))) + } + + // MARK: - Local Notifications + private func sendCompletionNotification(success: Bool, errorMessage: String? = nil) { + DispatchQueue.main.async { + guard UIApplication.shared.applicationState != .active else { return } + + let content = UNMutableNotificationContent() + + if success { + content.title = "3D Model Ready!" + content.body = "Your furniture model has been generated and saved." + content.sound = .default + } else { + content.title = "Model Generation Failed" + content.body = errorMessage ?? "An error occurred during processing." + content.sound = .default + } + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print(" Notification error: \(error)") + } + } + } + } + + // MARK: - Cancel Processing + func cancelProcessing() { + currentSession?.cancel() + currentSession = nil + + lock.lock() + _isProcessing = false + lock.unlock() + + currentJob?.status = .cancelled + endBackgroundTask() + + print(" Processing cancelled by user") + } + + // MARK: - Get Current State + func getCurrentState() -> (isProcessing: Bool, progress: Float, status: String) { + lock.lock() + defer { lock.unlock() } + return (_isProcessing, _currentProgress, _currentStatus) + } +} + +// MARK: - Errors +enum ProcessingError: LocalizedError { + case alreadyProcessing + case processingFailed(String) + case sessionCreationFailed + + var errorDescription: String? { + switch self { + case .alreadyProcessing: + return "A model is already being processed. Please wait." + case .processingFailed(let message): + return message + case .sessionCreationFailed: + return "Failed to create photogrammetry session." + } + } +} diff --git a/Envision/Managers/RoomColorManager.swift b/Envision/Managers/RoomColorManager.swift new file mode 100644 index 0000000..9abac69 --- /dev/null +++ b/Envision/Managers/RoomColorManager.swift @@ -0,0 +1,133 @@ +import UIKit + +/// Manages saved colors for room elements, persisted per room URL +final class RoomColorManager { + + static let shared = RoomColorManager() + private init() {} + + // MARK: - Storage + /// Dictionary: roomURL.path -> [elementPrefix: colorHex] + private var colorStorage: [String: [String: String]] = [:] + + // MARK: - Keys for element types + static let wallKey = "wall" + static let floorKey = "floor" + static let doorKey = "door" + static let windowKey = "window" + static let tableKey = "table" + static let chairKey = "chair" + static let storageKey = "storage" + + // MARK: - Public API + + /// Save a color for a specific element type in a room + func saveColor(_ color: UIColor, for elementType: String, roomURL: URL) { + let roomKey = roomURL.path + let hexColor = color.toHex() + + if colorStorage[roomKey] == nil { + colorStorage[roomKey] = [:] + } + colorStorage[roomKey]?[elementType] = hexColor + + // Persist to disk + persistColors(for: roomURL) + } + + /// Get saved color for an element type, or nil if not set + func getColor(for elementType: String, roomURL: URL) -> UIColor? { + let roomKey = roomURL.path + + // Try memory cache first + if let hexColor = colorStorage[roomKey]?[elementType] { + return UIColor(hex: hexColor) + } + + // Try loading from disk + loadColors(for: roomURL) + + if let hexColor = colorStorage[roomKey]?[elementType] { + return UIColor(hex: hexColor) + } + + return nil + } + + /// Get all saved colors for a room + func getAllColors(for roomURL: URL) -> [String: UIColor] { + let roomKey = roomURL.path + + // Ensure colors are loaded + if colorStorage[roomKey] == nil { + loadColors(for: roomURL) + } + + var result: [String: UIColor] = [:] + colorStorage[roomKey]?.forEach { key, hexValue in + result[key] = UIColor(hex: hexValue) + } + return result + } + + /// Clear all saved colors for a room + func clearColors(for roomURL: URL) { + let roomKey = roomURL.path + colorStorage[roomKey] = nil + + // Remove from disk + let colorFileURL = colorFileURL(for: roomURL) + try? FileManager.default.removeItem(at: colorFileURL) + } + + // MARK: - Persistence + + private func colorFileURL(for roomURL: URL) -> URL { + let roomName = roomURL.deletingPathExtension().lastPathComponent + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsURL.appendingPathComponent("RoomColors/\(roomName)_colors.json") + } + + private func persistColors(for roomURL: URL) { + let roomKey = roomURL.path + guard let colors = colorStorage[roomKey] else { return } + + let fileURL = colorFileURL(for: roomURL) + + // Create directory if needed + let directory = fileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + // Save as JSON + if let data = try? JSONEncoder().encode(colors) { + try? data.write(to: fileURL) + } + } + + private func loadColors(for roomURL: URL) { + let roomKey = roomURL.path + let fileURL = colorFileURL(for: roomURL) + + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + + if let data = try? Data(contentsOf: fileURL), + let colors = try? JSONDecoder().decode([String: String].self, from: data) { + colorStorage[roomKey] = colors + } + } +} + +// MARK: - UIColor Extension for Hex Output +extension UIColor { + func toHex() -> String { + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + + getRed(&r, green: &g, blue: &b, alpha: &a) + + let rgb: Int = (Int)(r*255)<<16 | (Int)(g*255)<<8 | (Int)(b*255)<<0 + return String(format: "#%06x", rgb) + } +} diff --git a/Envision/Managers/TourManager.swift b/Envision/Managers/TourManager.swift new file mode 100644 index 0000000..aa6bdc9 --- /dev/null +++ b/Envision/Managers/TourManager.swift @@ -0,0 +1,169 @@ +// +// TourManager.swift +// Envision +// +// Created for EnVision Tips & Tour System +// Version: 1.0 +// + +import Foundation +// TipKit temporarily removed from the project. + +/// Centralized manager for tour state, progress tracking, and tour lifecycle +final class TourManager { + + // MARK: - Singleton + + static let shared = TourManager() + + // MARK: - Private Properties + + private let userDefaults = UserDefaults.standard + private let tourCompletedKey = "app_tour_completed" + private let tourStepKey = "current_tour_step" + private let hasSeenWelcomeKey = "has_seen_welcome" + private let firstLaunchKey = "is_first_launch" + + // MARK: - Public Properties + + /// Whether the tour has been completed + var isTourCompleted: Bool { + get { userDefaults.bool(forKey: tourCompletedKey) } + set { userDefaults.set(newValue, forKey: tourCompletedKey) } + } + + /// Current step in the tour sequence (0-based) + var currentTourStep: Int { + get { userDefaults.integer(forKey: tourStepKey) } + set { userDefaults.set(newValue, forKey: tourStepKey) } + } + + /// Whether user has seen the welcome tip + var hasSeenWelcome: Bool { + get { userDefaults.bool(forKey: hasSeenWelcomeKey) } + set { userDefaults.set(newValue, forKey: hasSeenWelcomeKey) } + } + + /// Whether this is the first app launch + var isFirstLaunch: Bool { + get { !userDefaults.bool(forKey: firstLaunchKey) } + set { userDefaults.set(!newValue, forKey: firstLaunchKey) } + } + + // MARK: - Initialization + + private init() { + // Check if first launch + if isFirstLaunch { + print(" First launch detected - Tour will be shown") + } + } + + // MARK: - Tour Control Methods + + /// Determines if the tour should be shown + /// - Returns: Bool indicating if tour should be shown + func shouldShowTour() -> Bool { + // Don't show if already completed + if isTourCompleted { + return false + } + + // Show for first-time users or users who haven't completed + return isFirstLaunch || !hasSeenWelcome + } + + /// Initiates the tour sequence + func startTour() { + currentTourStep = 0 + isTourCompleted = false + hasSeenWelcome = true + isFirstLaunch = false + print("🎬 Tour started") + } + + /// Marks the tour as completed + func completeTour() { + isTourCompleted = true + currentTourStep = 0 + print(" Tour completed") + } + + /// Resets all tour progress and tips + func resetTour() { + isTourCompleted = false + currentTourStep = 0 + hasSeenWelcome = false + + // TipKit temporarily removed from the project. + + print("🔄 Tour reset complete") + } + + /// Advances to the next tour step + func nextStep() { + currentTourStep += 1 + print(" Tour step: \(currentTourStep)") + } + + /// Skips to a specific step + /// - Parameter step: The step number to skip to + func skipToStep(_ step: Int) { + currentTourStep = step + print(" Skipped to step: \(step)") + } + + // MARK: - Helper Methods + + /// Checks if user has any rooms + func hasRooms() -> Bool { + return !SaveManager.shared.getSavedModels(type: .room).isEmpty + } + + /// Checks if user has any furniture + func hasFurniture() -> Bool { + return !SaveManager.shared.getSavedModels(type: .furniture).isEmpty + } + + /// Checks if user has both rooms and furniture + func hasRoomsAndFurniture() -> Bool { + return hasRooms() && hasFurniture() + } + + /// Gets the count of rooms + func roomCount() -> Int { + return SaveManager.shared.getSavedModels(type: .room).count + } + + /// Gets the count of furniture + func furnitureCount() -> Int { + return SaveManager.shared.getSavedModels(type: .furniture).count + } + + // MARK: - Debug Methods + + #if DEBUG + func debugPrintState() { + print(""" + ═══════════════════════════════════ + TOUR DEBUG STATE + ═══════════════════════════════════ + Is First Launch: \(isFirstLaunch) + Tour Completed: \(isTourCompleted) + Current Step: \(currentTourStep) + Has Seen Welcome: \(hasSeenWelcome) + Should Show Tour: \(shouldShowTour()) + Has Rooms: \(hasRooms()) + Has Furniture: \(hasFurniture()) + Room Count: \(roomCount()) + Furniture Count: \(furnitureCount()) + ═══════════════════════════════════ + """) + } + + func forceShowTour() { + resetTour() + print(" Tour forced to show") + } + #endif +} diff --git a/Envision/SceneDelegate.swift b/Envision/SceneDelegate.swift index 127602c..3d25b60 100644 --- a/Envision/SceneDelegate.swift +++ b/Envision/SceneDelegate.swift @@ -18,8 +18,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = (scene as? UIWindowScene) else { return } let window = UIWindow(windowScene: windowScene) - window.rootViewController = SplashViewController() - // window.rootViewController = MainTabBarController() + //window.rootViewController = SplashViewController() + window.rootViewController = MainTabBarController() self.window = window window.makeKeyAndVisible() diff --git a/Envision/Screens/.DS_Store b/Envision/Screens/.DS_Store index 68f793b..a07d75f 100644 Binary files a/Envision/Screens/.DS_Store and b/Envision/Screens/.DS_Store differ diff --git a/Envision/Screens/MainTabs/Rooms/MetadataManager.swift b/Envision/Screens/MainTabs/Rooms/MetadataManager.swift index 1b4dae0..2ad2c2e 100644 --- a/Envision/Screens/MainTabs/Rooms/MetadataManager.swift +++ b/Envision/Screens/MainTabs/Rooms/MetadataManager.swift @@ -48,15 +48,15 @@ class MetadataManager { decoder.dateDecodingStrategy = .iso8601 let metadata = try decoder.decode(RoomsMetadata.self, from: data) - print("✅ Loaded metadata with \(metadata.rooms.count) rooms") + print(" Loaded metadata with \(metadata.rooms.count) rooms") cache = metadata return metadata } catch { - print("❌ Error loading metadata: \(error)") + print(" Error loading metadata: \(error)") // If file is corrupted, create backup and start fresh let backupURL = url.deletingPathExtension().appendingPathExtension("backup.json") try? FileManager.default.copyItem(at: url, to: backupURL) - print("📦 Created backup at: \(backupURL.lastPathComponent)") + print(" Created backup at: \(backupURL.lastPathComponent)") let empty = RoomsMetadata(version: "1.0", rooms: [:]) cache = empty @@ -76,13 +76,13 @@ class MetadataManager { let data = try encoder.encode(metadata) try data.write(to: self.metadataFileURL(), options: .atomic) - print("✅ Saved metadata with \(metadata.rooms.count) rooms") + print(" Saved metadata with \(metadata.rooms.count) rooms") DispatchQueue.main.async { self.cache = metadata } } catch { - print("❌ Error saving metadata: \(error)") + print(" Error saving metadata: \(error)") } } } @@ -96,7 +96,7 @@ class MetadataManager { allMetadata.rooms[filename] = metadata saveMetadata(allMetadata) - print("📝 Updated metadata for: \(filename)") + print(" Updated metadata for: \(filename)") } func deleteMetadata(for filename: String) { @@ -104,7 +104,7 @@ class MetadataManager { allMetadata.rooms.removeValue(forKey: filename) saveMetadata(allMetadata) - print("🗑️ Deleted metadata for: \(filename)") + print(" Deleted metadata for: \(filename)") } func renameMetadata(from oldFilename: String, to newFilename: String) { @@ -115,7 +115,7 @@ class MetadataManager { allMetadata.rooms[newFilename] = metadata saveMetadata(allMetadata) - print("✏️ Renamed metadata: \(oldFilename) → \(newFilename)") + print(" Renamed metadata: \(oldFilename) → \(newFilename)") } } @@ -134,7 +134,7 @@ class MetadataManager { if beforeCount != afterCount { saveMetadata(metadata) - print("🧹 Cleaned up \(beforeCount - afterCount) orphaned metadata entries") + print(" Cleaned up \(beforeCount - afterCount) orphaned metadata entries") } } @@ -145,7 +145,7 @@ class MetadataManager { // Debug method to print all metadata func printAllMetadata() { let metadata = loadMetadata() - print("📊 Metadata Summary:") + print(" Metadata Summary:") print(" Total rooms: \(metadata.rooms.count)") print(" File location: \(metadataFileURL().path)") @@ -183,4 +183,4 @@ struct RoomMetadata: Codable { self.tags = tags self.notes = notes } -} \ No newline at end of file +} diff --git a/Envision/Screens/MainTabs/Rooms/MyRoomsViewController+helpers.swift b/Envision/Screens/MainTabs/Rooms/MyRoomsViewController+helpers.swift index 1f49338..b39b707 100644 --- a/Envision/Screens/MainTabs/Rooms/MyRoomsViewController+helpers.swift +++ b/Envision/Screens/MainTabs/Rooms/MyRoomsViewController+helpers.swift @@ -43,15 +43,19 @@ extension MyRoomsViewController: UICollectionViewDataSource, UICollectionViewDel cell.button.addTarget(self, action: #selector(chipTapped(_:)), for: .touchUpInside) return cell } else { - // Room cell - UPDATED VERSION WITH BADGES + // Room cell let url = displayFiles[indexPath.item] let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RoomCell.reuseID, for: indexPath) as! RoomCell + + // ✅ ADD THIS LINE (IMPORTANT) + cell.setSelectionMode(isSelectionMode, animated: false) + let metadata = loadMetadata(for: url) - // Configure with metadata for badge display cell.configure( fileName: url.lastPathComponent, size: fileSizeString(for: url), + dateText: fileDateString(for: url), thumbnail: nil, category: metadata?.category, roomType: metadata?.roomType @@ -59,14 +63,15 @@ extension MyRoomsViewController: UICollectionViewDataSource, UICollectionViewDel generateThumbnail(for: url) { [weak self] image in guard let self = self, - let cell = self.collectionView.cellForItem(at: indexPath) as? RoomCell + let cell = self.collectionView.cellForItem(at: indexPath) as? RoomCell else { return } + let metadata = self.loadMetadata(for: url) - // Update with thumbnail and metadata cell.configure( fileName: url.lastPathComponent, size: self.fileSizeString(for: url), + dateText: self.fileDateString(for: url), thumbnail: image, category: metadata?.category, roomType: metadata?.roomType @@ -77,8 +82,12 @@ extension MyRoomsViewController: UICollectionViewDataSource, UICollectionViewDel } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard indexPath.section == 1, !collectionView.allowsMultipleSelection else { return } + // MULTI-SELECTION MODE → just select + if isSelectionMode { return } + + // NORMAL MODE → open room + guard indexPath.section == 1 else { return } let url = displayFiles[indexPath.item] navigationController?.pushViewController(RoomViewerViewController(roomURL: url), animated: true) } diff --git a/Envision/Screens/MainTabs/Rooms/MyRoomsViewController.swift b/Envision/Screens/MainTabs/Rooms/MyRoomsViewController.swift index 18766c9..372f7b6 100644 --- a/Envision/Screens/MainTabs/Rooms/MyRoomsViewController.swift +++ b/Envision/Screens/MainTabs/Rooms/MyRoomsViewController.swift @@ -8,6 +8,7 @@ import RoomPlan import QuickLook import QuickLookThumbnailing import UniformTypeIdentifiers +// TipKit temporarily removed from the project. final class MyRoomsViewController: UIViewController { @@ -19,12 +20,16 @@ final class MyRoomsViewController: UIViewController { private let searchController = UISearchController(searchResultsController: nil) private var refreshControl: UIRefreshControl! var previewURL: URL! + private var emptyStateView: UIView! + + // Tips temporarily removed // MARK: - Data var roomFiles: [URL] = [] var selectedCategory: RoomCategory? var selectedRoomType: RoomType? let thumbnailCache = NSCache() + var isSelectionMode = false private var isSearching: Bool { guard let text = searchController.searchBar.text else { return false } @@ -62,6 +67,18 @@ final class MyRoomsViewController: UIViewController { MetadataManager.shared.cleanupOrphanedMetadata() loadRoomFiles() + + // Tips temporarily removed + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Tips temporarily removed + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + // Tips temporarily removed } private func setupUI() { @@ -74,6 +91,9 @@ final class MyRoomsViewController: UIViewController { setupCollectionView() setupRefreshControl() setupLoadingOverlay() + setupEmptyState() + + // Tips temporarily removed } private func setupNavigationBar() { @@ -93,7 +113,7 @@ final class MyRoomsViewController: UIViewController { private func makeMenu() -> UIMenu { UIMenu(children: [ - UIAction(title: "Select Multiple", image: UIImage(systemName: "checkmark.circle")) { [weak self] _ in + UIAction(title: "Select Rooms", image: UIImage(systemName: "checkmark.circle")) { [weak self] _ in self?.enableMultipleSelection() }, UIAction(title: "Delete All", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in @@ -127,11 +147,11 @@ final class MyRoomsViewController: UIViewController { view.addSubview(collectionView) NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: view.topAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) } private func makeChipsSection() -> NSCollectionLayoutSection { @@ -195,16 +215,16 @@ final class MyRoomsViewController: UIViewController { view.addSubview(loadingOverlay) NSLayoutConstraint.activate([ - loadingOverlay.centerXAnchor.constraint(equalTo: view.centerXAnchor), - loadingOverlay.centerYAnchor.constraint(equalTo: view.centerYAnchor), - loadingOverlay.widthAnchor.constraint(equalToConstant: 220), - loadingOverlay.heightAnchor.constraint(equalToConstant: 120), - activityIndicator.topAnchor.constraint(equalTo: loadingOverlay.contentView.topAnchor, constant: 18), - activityIndicator.centerXAnchor.constraint(equalTo: loadingOverlay.contentView.centerXAnchor), - loadingLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 10), - loadingLabel.leadingAnchor.constraint(equalTo: loadingOverlay.contentView.leadingAnchor, constant: 12), - loadingLabel.trailingAnchor.constraint(equalTo: loadingOverlay.contentView.trailingAnchor, constant: -12) - ]) + loadingOverlay.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingOverlay.centerYAnchor.constraint(equalTo: view.centerYAnchor), + loadingOverlay.widthAnchor.constraint(equalToConstant: 220), + loadingOverlay.heightAnchor.constraint(equalToConstant: 120), + activityIndicator.topAnchor.constraint(equalTo: loadingOverlay.contentView.topAnchor, constant: 18), + activityIndicator.centerXAnchor.constraint(equalTo: loadingOverlay.contentView.centerXAnchor), + loadingLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 10), + loadingLabel.leadingAnchor.constraint(equalTo: loadingOverlay.contentView.leadingAnchor, constant: 12), + loadingLabel.trailingAnchor.constraint(equalTo: loadingOverlay.contentView.trailingAnchor, constant: -12) + ]) } // MARK: - Actions @@ -249,15 +269,42 @@ final class MyRoomsViewController: UIViewController { } private func enableMultipleSelection() { + isSelectionMode = true collectionView.allowsMultipleSelection = true - navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(disableMultipleSelection)) - navigationItem.rightBarButtonItems = [UIBarButtonItem(image: UIImage(systemName: "trash"), style: .plain, target: self, action: #selector(deleteSelectedRooms))] + + // Show circles on visible cells + collectionView.visibleCells + .compactMap { $0 as? RoomCell } + .forEach { $0.setSelectionMode(true, animated: true) } + + let deleteButton = UIBarButtonItem( + image: UIImage(systemName: "trash"), + style: .plain, + target: self, + action: #selector(deleteSelectedRooms) + ) + deleteButton.tintColor = .systemRed + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Done", style: .prominent, target: self, action: #selector(disableMultipleSelection)) + navigationItem.rightBarButtonItems = [deleteButton] + // navigationItem.rightBarButtonItems = [UIBarButtonItem(image: UIImage(systemName: "trash"), style: .plain, target: self, action: #selector(deleteSelectedRooms))] + showToast(message: "Tap rooms to select, then tap delete") } @objc private func disableMultipleSelection() { + isSelectionMode = false collectionView.allowsMultipleSelection = false - collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: true) } + + // Hide circles + collectionView.visibleCells + .compactMap { $0 as? RoomCell } + .forEach { $0.setSelectionMode(false, animated: true) } + + // Clear selection + collectionView.indexPathsForSelectedItems? + .forEach { collectionView.deselectItem(at: $0, animated: false) } + setupNavigationBar() } @@ -328,6 +375,7 @@ final class MyRoomsViewController: UIViewController { self.collectionView.reloadData() self.hideLoading() self.refreshControl.endRefreshing() + self.updateEmptyState() } } } @@ -448,11 +496,20 @@ final class MyRoomsViewController: UIViewController { // MARK: - Thumbnails func generateThumbnail(for url: URL, completion: @escaping (UIImage?) -> Void) { + // Check memory cache first if let cached = thumbnailCache.object(forKey: url as NSURL) { completion(cached) return } + + // Check for saved colored thumbnail + if let savedThumbnail = loadSavedThumbnail(for: url) { + thumbnailCache.setObject(savedThumbnail, forKey: url as NSURL) + completion(savedThumbnail) + return + } + // Fall back to QuickLook generator let req = QLThumbnailGenerator.Request( fileAt: url, size: CGSize(width: 400, height: 400), @@ -471,6 +528,20 @@ final class MyRoomsViewController: UIViewController { } } } + + private func loadSavedThumbnail(for roomURL: URL) -> UIImage? { + let roomName = roomURL.deletingPathExtension().lastPathComponent + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let thumbnailURL = documentsURL.appendingPathComponent("RoomThumbnails/\(roomName)_thumb.jpg") + + guard FileManager.default.fileExists(atPath: thumbnailURL.path), + let imageData = try? Data(contentsOf: thumbnailURL), + let image = UIImage(data: imageData) else { + return nil + } + + return image + } func fileSizeString(for url: URL) -> String { guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), @@ -481,6 +552,18 @@ final class MyRoomsViewController: UIViewController { return fmt.string(fromByteCount: size.int64Value) } + func fileDateString(for url: URL) -> String { + let fm = FileManager.default + if let attrs = try? fm.attributesOfItem(atPath: url.path), + let date = attrs[.creationDate] as? Date { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } + return "--" + } + // MARK: - UI Helpers private func showLoading(_ msg: String) { loadingLabel.text = msg @@ -493,6 +576,64 @@ final class MyRoomsViewController: UIViewController { activityIndicator.stopAnimating() } + // MARK: - Empty State + private func setupEmptyState() { + emptyStateView = UIView() + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + emptyStateView.isHidden = true + + let iconView = UIImageView() + iconView.image = UIImage(systemName: "house") + iconView.tintColor = .systemGray3 + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = "No Rooms Yet" + titleLabel.font = .boldSystemFont(ofSize: 22) + titleLabel.textColor = .label + titleLabel.textAlignment = .center + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let messageLabel = UILabel() + messageLabel.text = "Tap the camera icon to scan a room\nor import USDZ files" + messageLabel.font = .systemFont(ofSize: 16) + messageLabel.textColor = .secondaryLabel + messageLabel.textAlignment = .center + messageLabel.numberOfLines = 0 + messageLabel.translatesAutoresizingMaskIntoConstraints = false + + emptyStateView.addSubview(iconView) + emptyStateView.addSubview(titleLabel) + emptyStateView.addSubview(messageLabel) + view.addSubview(emptyStateView) + + NSLayoutConstraint.activate([ + emptyStateView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyStateView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + emptyStateView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 40), + emptyStateView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -40), + + iconView.topAnchor.constraint(equalTo: emptyStateView.topAnchor), + iconView.centerXAnchor.constraint(equalTo: emptyStateView.centerXAnchor), + iconView.widthAnchor.constraint(equalToConstant: 80), + iconView.heightAnchor.constraint(equalToConstant: 80), + + titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: emptyStateView.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: emptyStateView.trailingAnchor), + + messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + messageLabel.leadingAnchor.constraint(equalTo: emptyStateView.leadingAnchor), + messageLabel.trailingAnchor.constraint(equalTo: emptyStateView.trailingAnchor), + messageLabel.bottomAnchor.constraint(equalTo: emptyStateView.bottomAnchor) + ]) + } + + private func updateEmptyState() { + emptyStateView.isHidden = !roomFiles.isEmpty + } + func showToast(message: String) { let toast = UILabel() toast.text = message @@ -508,11 +649,11 @@ final class MyRoomsViewController: UIViewController { toast.alpha = 0 NSLayoutConstraint.activate([ - toast.centerXAnchor.constraint(equalTo: view.centerXAnchor), - toast.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), - toast.heightAnchor.constraint(equalToConstant: 40), - toast.widthAnchor.constraint(greaterThanOrEqualToConstant: 150) - ]) + toast.centerXAnchor.constraint(equalTo: view.centerXAnchor), + toast.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), + toast.heightAnchor.constraint(equalToConstant: 40), + toast.widthAnchor.constraint(greaterThanOrEqualToConstant: 150) + ]) UIView.animate(withDuration: 0.3) { toast.alpha = 1 @@ -559,6 +700,18 @@ final class MyRoomsViewController: UIViewController { return chips } + + // MARK: - Tips container (kept but unused; can be removed later safely) + private let tipContainerView: UIView = { + let v = UIView() + v.translatesAutoresizingMaskIntoConstraints = false + v.backgroundColor = .clear + v.isUserInteractionEnabled = true + v.isHidden = true + return v + }() + + private var tipContainerBottomConstraint: NSLayoutConstraint? } // MARK: - Models @@ -587,11 +740,11 @@ final class ChipCell: UICollectionViewCell { button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) contentView.addSubview(button) NSLayoutConstraint.activate([ - button.topAnchor.constraint(equalTo: contentView.topAnchor), - button.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) - ]) + button.topAnchor.constraint(equalTo: contentView.topAnchor), + button.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -608,17 +761,148 @@ final class ChipCell: UICollectionViewCell { button.backgroundColor = isSelected ? color : color.withAlphaComponent(0.1) button.tintColor = isSelected ? .white : color + let resolvedTint: UIColor = button.tintColor ?? color let attachment = NSTextAttachment() let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium) - attachment.image = UIImage(systemName: icon, withConfiguration: config)?.withTintColor(button.tintColor, renderingMode: .alwaysOriginal) + attachment.image = UIImage(systemName: icon, withConfiguration: config)?.withTintColor(resolvedTint, renderingMode: .alwaysOriginal) let attributedString = NSMutableAttributedString(attachment: attachment) attributedString.append(NSAttributedString(string: " \(title)", attributes: [ .font: UIFont.systemFont(ofSize: 14, weight: .medium), - .foregroundColor: button.tintColor + .foregroundColor: resolvedTint ])) button.setAttributedTitle(attributedString, for: .normal) } -} \ No newline at end of file +} + +// MARK: - TipKit Integration (temporarily removed) +// @available(iOS 17.0, *) +// extension MyRoomsViewController { +// +// private func setupTips() { +// // Create presenter for the screen-level tip container. +// if tipPresenter == nil { +// tipPresenter = TipPresenter(owner: self, containerView: tipContainerView) +// } +// } +// +// private func updateTipParameters() { +// MyRoomsIntroTip.hasRooms = !roomFiles.isEmpty +// RoomActionsMenuTip.roomCount = roomFiles.count +// RoomCategoriesTip.roomCount = roomFiles.count +// RoomImportTip.hasScannedRoom = !roomFiles.isEmpty +// } +// +// private func showContextualTip() { +// // Avoid showing tips while selecting or presenting another VC. +// if isSelectionMode { dismissTip(); return } +// if presentedViewController != nil { return } +// +// updateTipParameters() +// +// // Ensure any old SwiftUI-hosted tips are removed if they still exist (prevents ghost text). +// tipContainerView.subviews.forEach { $0.removeFromSuperview() } +// +// // Determine which tip to show (screen decides; TipPresenter just renders). +// var title: String? +// var message: String? +// var image: String? +// var actions: [TipPresenter.Action] = [] +// +// if roomFiles.isEmpty { +// title = "📐 Scan Your First Room" +// message = "Tap the green camera button to start scanning any room using your iPhone's LiDAR sensor. We'll create a precise 3D model!" +// image = "camera.viewfinder" +// actions = [ +// .init(id: "scan", title: "Scan Now"), +// .init(id: "later", title: "Maybe Later") +// ] +// } else if roomFiles.count == 1 { +// title = "📥 Already Have 3D Models?" +// message = "Tap the blue import button to bring in existing USDZ room models from your Files app." +// image = "square.and.arrow.down" +// actions = [ +// .init(id: "import", title: "Import"), +// .init(id: "later", title: "Later") +// ] +// } else if roomFiles.count >= 2 { +// title = "🏷️ Organize Your Spaces" +// message = "Use category chips to filter rooms by type. Long-press any room to edit its category and add custom tags." +// image = "tag.fill" +// actions = [ +// .init(id: "got-it", title: "Got it") +// ] +// } +// +// guard let title else { return } +// +// tipContainerView.isHidden = false +// setupTips() +// +// let height = tipPresenter?.present( +// title: title, +// message: message, +// systemImageName: image, +// actions: actions +// ) { [weak self] actionId in +// self?.handleTipActionId(actionId) +// } ?? 0 +// +// // Expand container to fit tip height, then update insets. +// tipContainerBottomConstraint?.isActive = false +// tipContainerBottomConstraint = tipContainerView.heightAnchor.constraint(equalToConstant: max(0, height)) +// tipContainerBottomConstraint?.priority = .required +// tipContainerBottomConstraint?.isActive = true +// +// updateCollectionInsetsForTip(containerHeight: height) +// } +// +// private func updateCollectionInsetsForTip(containerHeight: CGFloat) { +// guard let collectionView else { return } +// let topInset = max(0, containerHeight) + 12 +// var inset = collectionView.contentInset +// if abs(inset.top - topInset) > 1 { +// inset.top = topInset +// collectionView.contentInset = inset +// collectionView.scrollIndicatorInsets = inset +// } +// } +// +// private func dismissTip() { +// tipPresenter?.dismiss() +// tipContainerView.subviews.forEach { $0.removeFromSuperview() } +// tipContainerView.isHidden = true +// +// // Collapse container. +// tipContainerBottomConstraint?.isActive = false +// tipContainerBottomConstraint = tipContainerView.bottomAnchor.constraint(equalTo: tipContainerView.topAnchor) +// tipContainerBottomConstraint?.isActive = true +// +// // Reset insets. +// if let collectionView { +// var inset = collectionView.contentInset +// if inset.top != 0 { +// inset.top = 0 +// collectionView.contentInset = inset +// collectionView.scrollIndicatorInsets = inset +// } +// } +// } +// +// private func handleTipActionId(_ id: String) { +// switch id { +// case "scan": +// dismissTip() +// scanTapped() +// case "import": +// dismissTip() +// importTapped() +// case "later", "got-it": +// dismissTip() +// default: +// dismissTip() +// } +// } +// } diff --git a/Envision/Screens/MainTabs/Rooms/RoomCell.swift b/Envision/Screens/MainTabs/Rooms/RoomCell.swift index 6ad9717..077d93c 100644 --- a/Envision/Screens/MainTabs/Rooms/RoomCell.swift +++ b/Envision/Screens/MainTabs/Rooms/RoomCell.swift @@ -34,6 +34,15 @@ final class RoomCell: UICollectionViewCell { return lbl }() + private let dateLabel: UILabel = { + let lbl = UILabel() + lbl.translatesAutoresizingMaskIntoConstraints = false + lbl.font = .systemFont(ofSize: 11, weight: .regular) + lbl.textColor = .tertiaryLabel + lbl.numberOfLines = 1 + return lbl + }() + private let container: UIView = { let v = UIView() v.translatesAutoresizingMaskIntoConstraints = false @@ -43,7 +52,20 @@ final class RoomCell: UICollectionViewCell { return v }() - // Category Badge + // MARK: - Selection UI + private let selectionCircle: UIImageView = { + let iv = UIImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.image = UIImage(systemName: "circle") + iv.tintColor = .systemGray3 + iv.isHidden = true + return iv + }() + + private var containerLeadingConstraint: NSLayoutConstraint! + + // MARK: - Category Badge + private let categoryBadge: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false @@ -112,32 +134,41 @@ final class RoomCell: UICollectionViewCell { // MARK: - Setup private func setupUI() { + contentView.backgroundColor = .clear + contentView.addSubview(selectionCircle) contentView.addSubview(container) container.addSubview(thumbnailView) container.addSubview(titleLabel) container.addSubview(sizeLabel) + container.addSubview(dateLabel) - // Add badges thumbnailView.addSubview(categoryBadge) thumbnailView.addSubview(roomTypeBadge) - categoryBadge.addSubview(categoryIcon) - categoryBadge.addSubview(categoryLabel) - roomTypeBadge.addSubview(roomTypeIcon) roomTypeBadge.addSubview(roomTypeLabel) - contentView.backgroundColor = .clear + categoryBadge.addSubview(categoryIcon) + categoryBadge.addSubview(categoryLabel) + contentView.layer.shadowColor = UIColor.black.cgColor contentView.layer.shadowOffset = CGSize(width: 0, height: 2) contentView.layer.shadowRadius = 4 contentView.layer.shadowOpacity = 0.1 + containerLeadingConstraint = container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12) + NSLayoutConstraint.activate([ + // Selection Circle + selectionCircle.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), + selectionCircle.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + selectionCircle.widthAnchor.constraint(equalToConstant: 30), + selectionCircle.heightAnchor.constraint(equalToConstant: 30), + // Container + containerLeadingConstraint, container.topAnchor.constraint(equalTo: contentView.topAnchor), - container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), // Thumbnail @@ -155,9 +186,14 @@ final class RoomCell: UICollectionViewCell { sizeLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), sizeLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), sizeLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), - sizeLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), - // MARK: - Category Badge constraints + // Date + dateLabel.topAnchor.constraint(equalTo: sizeLabel.bottomAnchor, constant: 2), + dateLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), + dateLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), + dateLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), + + // RoomType Badge roomTypeBadge.topAnchor.constraint(equalTo: thumbnailView.topAnchor, constant: 8), roomTypeBadge.trailingAnchor.constraint(equalTo: thumbnailView.trailingAnchor, constant: -8), roomTypeBadge.heightAnchor.constraint(equalToConstant: 24), @@ -171,7 +207,7 @@ final class RoomCell: UICollectionViewCell { roomTypeLabel.trailingAnchor.constraint(equalTo: roomTypeBadge.trailingAnchor, constant: -8), roomTypeLabel.centerYAnchor.constraint(equalTo: roomTypeBadge.centerYAnchor), - // CATEGORY Badge — BELOW roomType badge + // Category Badge categoryBadge.topAnchor.constraint(equalTo: roomTypeBadge.bottomAnchor, constant: 6), categoryBadge.trailingAnchor.constraint(equalTo: thumbnailView.trailingAnchor, constant: -8), categoryBadge.heightAnchor.constraint(equalToConstant: 24), @@ -183,32 +219,65 @@ final class RoomCell: UICollectionViewCell { categoryLabel.leadingAnchor.constraint(equalTo: categoryIcon.trailingAnchor, constant: 4), categoryLabel.trailingAnchor.constraint(equalTo: categoryBadge.trailingAnchor, constant: -8), - categoryLabel.centerYAnchor.constraint(equalTo: categoryBadge.centerYAnchor) + categoryLabel.centerYAnchor.constraint(equalTo: categoryBadge.centerYAnchor), ]) } + // MARK: - Selection Mode + + func setSelectionMode(_ enabled: Bool, animated: Bool) { + let changes = { + self.selectionCircle.isHidden = !enabled + self.containerLeadingConstraint.constant = enabled ? 60 : 12 + self.layoutIfNeeded() + } + + animated + ? UIView.animate(withDuration: 0.25, animations: changes) + : changes() + } + + override var isSelected: Bool { + didSet { + let imageName = isSelected ? "checkmark.circle.fill" : "circle" + selectionCircle.image = UIImage(systemName: imageName) + selectionCircle.tintColor = isSelected ? .systemBlue : .systemGray3 + } + } + // MARK: - Reuse + override func prepareForReuse() { super.prepareForReuse() thumbnailView.image = nil titleLabel.text = nil sizeLabel.text = nil + dateLabel.text = nil categoryBadge.isHidden = true roomTypeBadge.isHidden = true + selectionCircle.image = UIImage(systemName: "circle") } // MARK: - Configure - func configure(fileName: String, size: String, thumbnail: UIImage?, category: RoomCategory? = nil, roomType: RoomType? = nil) { - titleLabel.text = fileName + func configure( + fileName: String, + size: String, + dateText: String, + thumbnail: UIImage?, + category: RoomCategory? = nil, + roomType: RoomType? = nil + ) { + // Remove extension from fileName + let nameWithoutExtension = (fileName as NSString).deletingPathExtension + titleLabel.text = nameWithoutExtension sizeLabel.text = size - thumbnailView.image = thumbnail ?? UIImage(systemName: "arkit")! + dateLabel.text = dateText + thumbnailView.image = thumbnail ?? UIImage(systemName: "arkit") - // Reset badges categoryBadge.isHidden = true roomTypeBadge.isHidden = true - // Category Badge if let category = category { categoryIcon.image = UIImage(systemName: category.sfSymbol) categoryIcon.tintColor = category.color @@ -217,7 +286,6 @@ final class RoomCell: UICollectionViewCell { categoryBadge.isHidden = false } - // RoomType Badge if let roomType = roomType { roomTypeIcon.image = UIImage(systemName: roomType.sfSymbol) roomTypeIcon.tintColor = roomType.color @@ -226,12 +294,4 @@ final class RoomCell: UICollectionViewCell { roomTypeBadge.isHidden = false } } - - /// Legacy version for backward compatibility - func configure(with model: RoomModel) { - titleLabel.text = model.name ?? "Room" - thumbnailView.image = model.thumbnail ?? UIImage(systemName: "square.split.2x2")! - categoryBadge.isHidden = true - roomTypeBadge.isHidden = true - } } diff --git a/Envision/Screens/MainTabs/Rooms/RoomPlanScan/RoomPreviewViewController.swift b/Envision/Screens/MainTabs/Rooms/RoomPlanScan/RoomPreviewViewController.swift index 752c352..5c2359d 100644 --- a/Envision/Screens/MainTabs/Rooms/RoomPlanScan/RoomPreviewViewController.swift +++ b/Envision/Screens/MainTabs/Rooms/RoomPlanScan/RoomPreviewViewController.swift @@ -95,7 +95,7 @@ final class RoomPreviewViewController: UIViewController { private let saveButton: UIButton = { let btn = UIButton(type: .system) - btn.setTitle("💾 Save to My Rooms", for: .normal) + btn.setTitle("Save to My Rooms", for: .normal) btn.setTitle("✓ Saved!", for: .disabled) btn.titleLabel?.font = AppFonts.semibold(17) btn.backgroundColor = AppColors.accent @@ -434,9 +434,9 @@ final class RoomPreviewViewController: UIViewController { self.showSuccessAnimation() case .failure(let error): - print("❌ Save error: \(error)") + print(" Save error: \(error)") self.saveButton.isEnabled = true - self.saveButton.setTitle("💾 Save to My Rooms", for: .normal) + self.saveButton.setTitle(" Save to My Rooms", for: .normal) self.showErrorAlert(message: "Failed to save room. Please try again.") } } @@ -546,4 +546,4 @@ extension RoomPreviewViewController: UITextFieldDelegate { textField.resignFirstResponder() return true } -} \ No newline at end of file +} diff --git a/Envision/Screens/MainTabs/Rooms/furniture+room/RoomEditVC.swift b/Envision/Screens/MainTabs/Rooms/furniture+room/RoomEditVC.swift index 585e750..fa27632 100644 --- a/Envision/Screens/MainTabs/Rooms/furniture+room/RoomEditVC.swift +++ b/Envision/Screens/MainTabs/Rooms/furniture+room/RoomEditVC.swift @@ -200,13 +200,79 @@ final class RoomEditVC: UIViewController { // MARK: - Navigation private func setupNavigation() { - navigationItem.rightBarButtonItem = UIBarButtonItem( + // Save button to save colors and go back + let saveButton = UIBarButtonItem( + title: "Save", + style: .done, + target: self, + action: #selector(saveAndGoBack) + ) + saveButton.tintColor = .systemGreen + + // Add furniture button + let addButton = UIBarButtonItem( image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addFurnitureTapped) ) - navigationItem.rightBarButtonItem?.tintColor = .systemGreen + addButton.tintColor = .systemBlue + + navigationItem.rightBarButtonItems = [saveButton, addButton] + } + + @objc private func saveAndGoBack() { + // Generate thumbnail with current colors + generateAndSaveThumbnail() + + // Show success feedback + let alert = UIAlertController( + title: "Saved", + message: "Room colors have been saved successfully.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + // Navigate back to home (My Rooms) + self?.navigationController?.popToRootViewController(animated: true) + }) + present(alert, animated: true) + } + + private func generateAndSaveThumbnail() { + // Capture current ARView as thumbnail + let renderer = UIGraphicsImageRenderer(size: arView.bounds.size) + let thumbnail = renderer.image { _ in + arView.drawHierarchy(in: arView.bounds, afterScreenUpdates: true) + } + + // Save thumbnail to room's thumbnail location + saveThumbnail(thumbnail, for: roomURL) + } + + private func saveThumbnail(_ image: UIImage, for roomURL: URL) { + let roomName = roomURL.deletingPathExtension().lastPathComponent + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let thumbnailsDir = documentsURL.appendingPathComponent("RoomThumbnails") + + // Create thumbnails directory if needed + try? FileManager.default.createDirectory(at: thumbnailsDir, withIntermediateDirectories: true) + + let thumbnailURL = thumbnailsDir.appendingPathComponent("\(roomName)_thumb.jpg") + + // Resize image for thumbnail (smaller file size) + let resizedImage = resizeImage(image, to: CGSize(width: 400, height: 300)) + + if let jpegData = resizedImage.jpegData(compressionQuality: 0.8) { + try? jpegData.write(to: thumbnailURL) + print("✅ Saved thumbnail to: \(thumbnailURL.path)") + } + } + + private func resizeImage(_ image: UIImage, to size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } } // MARK: - Gestures @@ -326,6 +392,8 @@ final class RoomEditVC: UIViewController { // MARK: - Materials & Labels private func applyMaterialRules(to root: Entity) { var entitiesFound:[String] = [] + let savedColors = RoomColorManager.shared.getAllColors(for: roomURL) + root.visit { guard let model = $0 as? ModelEntity else { return } @@ -334,43 +402,69 @@ final class RoomEditVC: UIViewController { originalMaterials[model] = model.model?.materials } - // 🔴 Enable Colors OFF → force everything white + // 🔴 Enable Colors OFF → apply saved colors or white guard enableColors else { - model.model?.materials = [SimpleMaterial(color: .white.withAlphaComponent(0.9), roughness: 0.8, isMetallic: false)] + let name = model.name.lowercased() + + // Check for saved colors first + if name.starts(with: "wall"), let color = savedColors[RoomColorManager.wallKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } else if name.starts(with: "floor"), let color = savedColors[RoomColorManager.floorKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.6, isMetallic: false)] + } else if name.starts(with: "door"), let color = savedColors[RoomColorManager.doorKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } else if name.starts(with: "window"), let color = savedColors[RoomColorManager.windowKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } else if name.starts(with: "table"), let color = savedColors[RoomColorManager.tableKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } else if name.starts(with: "chair"), let color = savedColors[RoomColorManager.chairKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } else if name.starts(with: "storage"), let color = savedColors[RoomColorManager.storageKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } else { + model.model?.materials = [SimpleMaterial(color: .white.withAlphaComponent(0.9), roughness: 0.8, isMetallic: false)] + } return } - // 🟢 Enable Colors ON → apply semantic colors + // 🟢 Enable Colors ON → apply saved colors or default semantic colors let name = model.name.lowercased() entitiesFound.append(name) switch true { case name.starts(with: "wall"): - model.model?.materials = [SimpleMaterial(color: .systemBlue, roughness: 0.4, isMetallic: false)] + let color = savedColors[RoomColorManager.wallKey] ?? .systemBlue + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] attachLabel(to: model, text: name, yOffset: 1.5) case name.starts(with: "floor"): - model.model?.materials = [SimpleMaterial(color: .gray, roughness: 0.6, isMetallic: false)] + let color = savedColors[RoomColorManager.floorKey] ?? .gray + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.6, isMetallic: false)] attachLabel(to: model, text: name, yOffset: 0.05) case name.starts(with: "chair"): - model.model?.materials = [SimpleMaterial(color: .black, roughness: 0.4, isMetallic: false)] + let color = savedColors[RoomColorManager.chairKey] ?? .black + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] attachLabel(to: model, text: name, yOffset: 0.15) case name.starts(with: "table"): - model.model?.materials = [SimpleMaterial(color: .systemRed, roughness: 0.4, isMetallic: false)] + let color = savedColors[RoomColorManager.tableKey] ?? .systemRed + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] attachLabel(to: model, text: name, yOffset: 0.5) case name.starts(with: "door"): - model.model?.materials = [SimpleMaterial(color: .systemCyan.withAlphaComponent(0.3), roughness: 0.4, isMetallic: false)] + let color = savedColors[RoomColorManager.doorKey] ?? .systemCyan.withAlphaComponent(0.3) + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] attachLabel(to: model, text: name, yOffset: 0.8) case name.starts(with: "window"): - model.model?.materials = [SimpleMaterial(color: .lightGray.withAlphaComponent(0.3), roughness: 0.4, isMetallic: false)] + let color = savedColors[RoomColorManager.windowKey] ?? .lightGray.withAlphaComponent(0.3) + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] attachLabel(to: model, text: name, yOffset: 0.4) case name.starts(with: "storage"): - model.model?.materials = [SimpleMaterial(color: .systemOrange, roughness: 0.4, isMetallic: false)] + let color = savedColors[RoomColorManager.storageKey] ?? .systemOrange + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] attachLabel(to: model, text: name, yOffset: 0.4) default: @@ -433,11 +527,51 @@ final class RoomEditVC: UIViewController { colorTarget = target let picker = UIColorPickerViewController() picker.delegate = self - present(picker, animated: true) + picker.supportsAlpha = true + + // Wrap in navigation controller to add custom buttons + let navController = UINavigationController(rootViewController: picker) + navController.modalPresentationStyle = .pageSheet + + // Add native Cancel button (left side) + let cancelButton = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelColorPicker) + ) + picker.navigationItem.leftBarButtonItem = cancelButton + + // Add native Done button (right side) + let doneButton = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissColorPicker) + ) + picker.navigationItem.rightBarButtonItem = doneButton + picker.navigationItem.title = "Choose Color" + + present(navController, animated: true) + } + + @objc private func cancelColorPicker() { + // Restore original materials when cancelling + if let model = displayedModel { + applyMaterialRules(to: model) + } + dismiss(animated: true) + } + + @objc private func dismissColorPicker() { + dismiss(animated: true) } private func attachLabel(to entity: Entity, text: String, yOffset: Float) { - labels[entity]?.removeFromParent() + // Remove existing label safely + if let existingLabel = labels[entity] { + existingLabel.components.remove(BillboardComponent.self) + existingLabel.removeFromParent() + labels[entity] = nil + } let mesh = MeshResource.generateText( text, @@ -447,11 +581,31 @@ final class RoomEditVC: UIViewController { let label = ModelEntity(mesh: mesh, materials: [SimpleMaterial(color: .white, isMetallic: false)]) label.position = [0, yOffset, 0] - label.components.set(BillboardComponent()) label.isEnabled = showLabels - + + // Add to parent first, then set BillboardComponent on main thread entity.addChild(label) labels[entity] = label + + // Set BillboardComponent after a brief delay to avoid crash + DispatchQueue.main.async { [weak label] in + guard let label = label, label.parent != nil else { return } + label.components.set(BillboardComponent()) + } + } + + // MARK: - Cleanup + private func cleanupLabels() { + for (_, label) in labels { + label.components.remove(BillboardComponent.self) + label.removeFromParent() + } + labels.removeAll() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cleanupLabels() } // MARK: - Furniture @@ -505,24 +659,83 @@ final class RoomEditVC: UIViewController { } private func presentColorPicker() { + colorTarget = .selected let picker = UIColorPickerViewController() picker.delegate = self - present(picker, animated: true) + picker.supportsAlpha = true + + // Wrap in navigation controller to add custom buttons + let navController = UINavigationController(rootViewController: picker) + navController.modalPresentationStyle = .pageSheet + + // Add native Cancel button (left side) + let cancelButton = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelColorPicker) + ) + picker.navigationItem.leftBarButtonItem = cancelButton + + // Add native Done button (right side) + let doneButton = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissColorPicker) + ) + picker.navigationItem.rightBarButtonItem = doneButton + picker.navigationItem.title = "Choose Color" + + present(navController, animated: true) } } // MARK: - Color Picker extension RoomEditVC: UIColorPickerViewControllerDelegate { func colorPickerViewControllerDidSelectColor(_ viewController: UIColorPickerViewController) { + let selectedColor = viewController.selectedColor switch colorTarget { case .selected: selectedModel?.model?.materials = [ - SimpleMaterial(color: viewController.selectedColor, roughness: 0.4, isMetallic: false) + SimpleMaterial(color: selectedColor, roughness: 0.4, isMetallic: false) ] + // Save color for selected element type + if let modelName = selectedModel?.name.lowercased() { + let elementType = getElementType(from: modelName) + if let type = elementType { + RoomColorManager.shared.saveColor(selectedColor, for: type, roomURL: roomURL) + } + } default: - setColor(for: colorTarget, color: viewController.selectedColor) + setColor(for: colorTarget, color: selectedColor) + // Save color for the target type + if let elementType = colorTargetToElementType(colorTarget) { + RoomColorManager.shared.saveColor(selectedColor, for: elementType, roomURL: roomURL) + } + } + } + + private func getElementType(from name: String) -> String? { + if name.starts(with: "wall") { return RoomColorManager.wallKey } + if name.starts(with: "floor") { return RoomColorManager.floorKey } + if name.starts(with: "door") { return RoomColorManager.doorKey } + if name.starts(with: "window") { return RoomColorManager.windowKey } + if name.starts(with: "table") { return RoomColorManager.tableKey } + if name.starts(with: "chair") { return RoomColorManager.chairKey } + if name.starts(with: "storage") { return RoomColorManager.storageKey } + return nil + } + + private func colorTargetToElementType(_ target: ColorTarget) -> String? { + switch target { + case .walls: return RoomColorManager.wallKey + case .floors: return RoomColorManager.floorKey + case .doors: return RoomColorManager.doorKey + case .windows: return RoomColorManager.windowKey + case .tables: return RoomColorManager.tableKey + case .storage: return RoomColorManager.storageKey + case .selected: return nil } } } diff --git a/Envision/Screens/MainTabs/Rooms/furniture+room/RoomVisualizeVC.swift b/Envision/Screens/MainTabs/Rooms/furniture+room/RoomVisualizeVC.swift index d5fdefc..55b69b4 100644 --- a/Envision/Screens/MainTabs/Rooms/furniture+room/RoomVisualizeVC.swift +++ b/Envision/Screens/MainTabs/Rooms/furniture+room/RoomVisualizeVC.swift @@ -10,6 +10,10 @@ final class RoomVisualizeVC: UIViewController { private var roomModel: ModelEntity? private var displayedModel: ModelEntity? private var placedFurniture: [ModelEntity] = [] + private var isMeasuringMode = false + private var measurementPoints: [SIMD3] = [] + private var measurementLabel: UILabel? + private var measurementLine: ModelEntity? // MARK: - Camera private let cameraAnchor = AnchorEntity() @@ -65,13 +69,205 @@ final class RoomVisualizeVC: UIViewController { // MARK: - Navigation private func setupNavigation() { - navigationItem.rightBarButtonItem = UIBarButtonItem( + let rulerButton = UIBarButtonItem( + image: UIImage(systemName: "ruler"), + style: .plain, + target: self, + action: #selector(rulerTapped) + ) + rulerButton.tintColor = .systemBlue + + let addButton = UIBarButtonItem( image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addFurnitureTapped) ) - navigationItem.rightBarButtonItem?.tintColor = .systemGreen + addButton.tintColor = .systemGreen + + navigationItem.rightBarButtonItems = [addButton, rulerButton] + } + + @objc private func rulerTapped() { + isMeasuringMode.toggle() + + // Update button appearance + if let rulerButton = navigationItem.rightBarButtonItems?.last { + rulerButton.tintColor = isMeasuringMode ? .systemOrange : .systemBlue + rulerButton.image = UIImage(systemName: isMeasuringMode ? "ruler.fill" : "ruler") + } + + if isMeasuringMode { + showMeasurementInstructions() + setupMeasurementTapGesture() + } else { + clearMeasurement() + removeMeasurementTapGesture() + } + } + + private func showMeasurementInstructions() { + let toast = UILabel() + toast.text = "Tap two points to measure distance" + toast.font = .systemFont(ofSize: 15, weight: .medium) + toast.textColor = .white + toast.backgroundColor = UIColor.systemOrange.withAlphaComponent(0.9) + toast.textAlignment = .center + toast.layer.cornerRadius = 12 + toast.clipsToBounds = true + toast.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(toast) + + NSLayoutConstraint.activate([ + toast.centerXAnchor.constraint(equalTo: view.centerXAnchor), + toast.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10), + toast.heightAnchor.constraint(equalToConstant: 40), + toast.widthAnchor.constraint(greaterThanOrEqualToConstant: 250) + ]) + + UIView.animate(withDuration: 0.3, delay: 2.5) { + toast.alpha = 0 + } completion: { _ in + toast.removeFromSuperview() + } + } + + private var measurementTapGesture: UITapGestureRecognizer? + + private func setupMeasurementTapGesture() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleMeasurementTap(_:))) + arView.addGestureRecognizer(tap) + measurementTapGesture = tap + } + + private func removeMeasurementTapGesture() { + if let gesture = measurementTapGesture { + arView.removeGestureRecognizer(gesture) + measurementTapGesture = nil + } + } + + @objc private func handleMeasurementTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: arView) + + // Raycast to find 3D point + guard let result = arView.entity(at: location) else { return } + + let worldPosition = result.position(relativeTo: nil) + measurementPoints.append(worldPosition) + + // Add visual marker at tap point + addMeasurementMarker(at: worldPosition) + + if measurementPoints.count == 2 { + calculateAndDisplayDistance() + } else if measurementPoints.count > 2 { + // Reset and start new measurement + clearMeasurement() + measurementPoints.append(worldPosition) + addMeasurementMarker(at: worldPosition) + } + } + + private func addMeasurementMarker(at position: SIMD3) { + let sphere = MeshResource.generateSphere(radius: 0.02) + let material = SimpleMaterial(color: .systemOrange, isMetallic: false) + let marker = ModelEntity(mesh: sphere, materials: [material]) + marker.position = position + marker.name = "measurementMarker" + + if let anchor = arView.scene.anchors.first { + anchor.addChild(marker) + } + } + + private func calculateAndDisplayDistance() { + guard measurementPoints.count >= 2 else { return } + + let point1 = measurementPoints[0] + let point2 = measurementPoints[1] + + // Calculate distance + let distance = simd_distance(point1, point2) + + // Convert to real-world units (assuming model scale) + let realDistance = distance / 0.005 // Adjust based on your model scale + + // Create line between points + drawMeasurementLine(from: point1, to: point2) + + // Display distance + showDistanceLabel(distance: realDistance) + } + + private func drawMeasurementLine(from start: SIMD3, to end: SIMD3) { + // Remove existing line + measurementLine?.removeFromParent() + + let distance = simd_distance(start, end) + let midPoint = (start + end) / 2 + + let mesh = MeshResource.generateBox(size: [0.005, 0.005, distance]) + let material = SimpleMaterial(color: .systemOrange, isMetallic: false) + let line = ModelEntity(mesh: mesh, materials: [material]) + + line.position = midPoint + line.look(at: end, from: midPoint, relativeTo: nil) + line.name = "measurementLine" + + if let anchor = arView.scene.anchors.first { + anchor.addChild(line) + } + measurementLine = line + } + + private func showDistanceLabel(distance: Float) { + measurementLabel?.removeFromSuperview() + + let label = UILabel() + let distanceInMeters = distance + let distanceInCm = distance * 100 + let distanceInFeet = distance * 3.28084 + + if distanceInMeters >= 1 { + label.text = String(format: "📏 %.2f m (%.1f ft)", distanceInMeters, distanceInFeet) + } else { + label.text = String(format: "📏 %.1f cm (%.1f in)", distanceInCm, distanceInCm / 2.54) + } + + label.font = .systemFont(ofSize: 18, weight: .bold) + label.textColor = .white + label.backgroundColor = UIColor.systemOrange.withAlphaComponent(0.95) + label.textAlignment = .center + label.layer.cornerRadius = 12 + label.clipsToBounds = true + label.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(label) + measurementLabel = label + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), + label.heightAnchor.constraint(equalToConstant: 50), + label.widthAnchor.constraint(greaterThanOrEqualToConstant: 200) + ]) + } + + private func clearMeasurement() { + measurementPoints.removeAll() + measurementLabel?.removeFromSuperview() + measurementLabel = nil + measurementLine?.removeFromParent() + measurementLine = nil + + // Remove all measurement markers + arView.scene.anchors.first?.children.forEach { entity in + if entity.name == "measurementMarker" || entity.name == "measurementLine" { + entity.removeFromParent() + } + } } // MARK: - Gestures @@ -83,9 +279,13 @@ final class RoomVisualizeVC: UIViewController { // MARK: - Loading private func loadRoom() { Task { - let entity = try await Entity.load(contentsOf: roomURL) - await MainActor.run { - setupScene(with: entity) + do { + let entity = try await Entity(contentsOf: roomURL) + await MainActor.run { + setupScene(with: entity) + } + } catch { + print("Failed to load room: \(error)") } } } @@ -105,6 +305,9 @@ final class RoomVisualizeVC: UIViewController { displayedModel = clone fitToScreen(clone) + + // Apply saved colors from RoomColorManager + applySavedColors(to: clone) let anchor = AnchorEntity(world: .zero) anchor.addChild(clone) @@ -112,6 +315,40 @@ final class RoomVisualizeVC: UIViewController { setupCamera() } + + private func applySavedColors(to root: ModelEntity) { + let savedColors = RoomColorManager.shared.getAllColors(for: roomURL) + + guard !savedColors.isEmpty else { return } + + root.visit { entity in + guard let model = entity as? ModelEntity else { return } + let name = model.name.lowercased() + + // Check each element type and apply saved color if available + if name.starts(with: "wall"), let color = savedColors[RoomColorManager.wallKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } + else if name.starts(with: "floor"), let color = savedColors[RoomColorManager.floorKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.6, isMetallic: false)] + } + else if name.starts(with: "door"), let color = savedColors[RoomColorManager.doorKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } + else if name.starts(with: "window"), let color = savedColors[RoomColorManager.windowKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } + else if name.starts(with: "table"), let color = savedColors[RoomColorManager.tableKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } + else if name.starts(with: "chair"), let color = savedColors[RoomColorManager.chairKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } + else if name.starts(with: "storage"), let color = savedColors[RoomColorManager.storageKey] { + model.model?.materials = [SimpleMaterial(color: color, roughness: 0.4, isMetallic: false)] + } + } + } private func fitToScreen(_ model: ModelEntity) { let bounds = model.visualBounds(relativeTo: nil) diff --git a/Envision/Screens/MainTabs/furniture/CreateModel/CreateModelViewController2.swift b/Envision/Screens/MainTabs/furniture/CreateModel/CreateModelViewController2.swift index 6d46328..197c0d0 100644 --- a/Envision/Screens/MainTabs/furniture/CreateModel/CreateModelViewController2.swift +++ b/Envision/Screens/MainTabs/furniture/CreateModel/CreateModelViewController2.swift @@ -59,24 +59,24 @@ class CreateModelViewController2: UIViewController { for try await output in session.outputs { switch output { case .processingComplete: - print("✔ Processing Completed") + print(" Processing Completed") case .requestError(let id, let error): - print("❌ Error in request \(id): \(error.localizedDescription)") + print(" Error in request \(id): \(error.localizedDescription)") case .requestProgress(let id, let progress): let percent = Int(progress * 100) - print("⏳ Progress (\(id)): \(percent)%") + print(" Progress (\(id)): \(percent)%") case .requestComplete(let id, let result): - print("🎉 Request \(id) completed: \(result)") + print(" Request \(id) completed: \(result)") default: break } } } catch { - print("❌ Session failed: \(error)") + print(" Session failed: \(error)") } } @@ -84,7 +84,7 @@ class CreateModelViewController2: UIViewController { try session.process(requests: [request]) } catch { - print("❌ Couldn't create PhotogrammetrySession: \(error)") + print(" Couldn't create PhotogrammetrySession: \(error)") } } diff --git a/Envision/Screens/MainTabs/furniture/Object Capture/ObjectCapturePreviewController.swift b/Envision/Screens/MainTabs/furniture/Object Capture/ObjectCapturePreviewController.swift index 576ef17..fcb0d49 100644 --- a/Envision/Screens/MainTabs/furniture/Object Capture/ObjectCapturePreviewController.swift +++ b/Envision/Screens/MainTabs/furniture/Object Capture/ObjectCapturePreviewController.swift @@ -15,6 +15,9 @@ final class ObjectCapturePreviewController: UIViewController { private var isProcessing = false private var imageURLs: [URL] = [] + // Background processing + private let processor = BackgroundModelProcessor.shared + private let scrollView: UIScrollView = { let sv = UIScrollView() sv.translatesAutoresizingMaskIntoConstraints = false @@ -130,6 +133,69 @@ final class ObjectCapturePreviewController: UIViewController { btn.translatesAutoresizingMaskIntoConstraints = false return btn }() + + // Quality selector + private let qualitySegment: UISegmentedControl = { + let items = ["Fast", "Balanced", "High Quality"] + let seg = UISegmentedControl(items: items) + seg.selectedSegmentIndex = 0 // Default to Fast + seg.translatesAutoresizingMaskIntoConstraints = false + return seg + }() + + private let qualityLabel: UILabel = { + let lbl = UILabel() + lbl.text = "Processing Speed" + lbl.font = .systemFont(ofSize: 14, weight: .medium) + lbl.textColor = .secondaryLabel + lbl.translatesAutoresizingMaskIntoConstraints = false + return lbl + }() + + private let qualityDescLabel: UILabel = { + let lbl = UILabel() + lbl.text = "⚡ Fastest processing, good for quick previews" + lbl.font = .systemFont(ofSize: 12) + lbl.textColor = .tertiaryLabel + lbl.textAlignment = .center + lbl.numberOfLines = 0 + lbl.translatesAutoresizingMaskIntoConstraints = false + return lbl + }() + + private let backgroundInfoLabel: UILabel = { + let lbl = UILabel() + lbl.text = "💡 You can leave this screen - processing continues in background" + lbl.font = .systemFont(ofSize: 13, weight: .medium) + lbl.textColor = .systemBlue + lbl.textAlignment = .center + lbl.numberOfLines = 0 + lbl.alpha = 0 + lbl.translatesAutoresizingMaskIntoConstraints = false + return lbl + }() + + private let cancelButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("Cancel Processing", for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.setTitleColor(.systemRed, for: .normal) + btn.backgroundColor = .systemRed.withAlphaComponent(0.1) + btn.layer.cornerRadius = 14 + btn.isHidden = true + btn.translatesAutoresizingMaskIntoConstraints = false + return btn + }() + + private let timeEstimateLabel: UILabel = { + let lbl = UILabel() + lbl.font = .systemFont(ofSize: 13) + lbl.textColor = .tertiaryLabel + lbl.textAlignment = .center + lbl.translatesAutoresizingMaskIntoConstraints = false + return lbl + }() + private var selectedDetailLevel: PhotogrammetrySession.Request.Detail = .reduced // MARK: - Init @@ -147,6 +213,8 @@ final class ObjectCapturePreviewController: UIViewController { loadImages() setupUI() + setupQualitySelector() + setupBackgroundProcessingCallbacks() generateButton.addTarget(self, action: #selector(startProcessing), for: .touchUpInside) retakeButton.addTarget(self, action: #selector(retakePhotos), for: .touchUpInside) @@ -154,6 +222,85 @@ final class ObjectCapturePreviewController: UIViewController { collectionView.delegate = self collectionView.dataSource = self + + // Check if processing is already in progress (user returned to screen) + checkExistingProcessing() + } + + // MARK: - Setup Quality Selector + private func setupQualitySelector() { + qualitySegment.addTarget(self, action: #selector(qualityChanged), for: .valueChanged) + updateQualityDescription() + } + + @objc private func qualityChanged() { + updateQualityDescription() + + // Haptic feedback + let generator = UISelectionFeedbackGenerator() + generator.selectionChanged() + } + + private func updateQualityDescription() { + switch qualitySegment.selectedSegmentIndex { + case 0: // Fast + selectedDetailLevel = .reduced + qualityDescLabel.text = " ~30s-1 min • Quick preview quality" + case 1: // Balanced + selectedDetailLevel = .reduced + qualityDescLabel.text = " ~1-3 min • Good balance of speed & quality" + case 2: // High Quality + selectedDetailLevel = .reduced + qualityDescLabel.text = "pl ~3-8 min • Maximum detail & textures" + default: + break + } + } + + // MARK: - Background Processing Callbacks + private func setupBackgroundProcessingCallbacks() { + // Progress updates + processor.onProgressUpdate = { [weak self] progress, status in + guard let self = self else { return } + self.progressView.setProgress(progress, animated: true) + self.progressLabel.text = "\(Int(progress * 100))%" + self.statusLabel.text = status + } + + // Completion + processor.onCompletion = { [weak self] savedURL in + guard let self = self else { return } + self.handleProcessingComplete(savedURL: savedURL) + } + + // Error + processor.onError = { [weak self] error in + guard let self = self else { return } + self.handleError(message: error) + } + } + + // MARK: - Check Existing Processing + private func checkExistingProcessing() { + let state = processor.getCurrentState() + + if state.isProcessing { + // Resume UI for ongoing processing + isProcessing = true + generateButton.isEnabled = false + retakeButton.isEnabled = false + qualitySegment.isEnabled = false + activityIndicator.startAnimating() + + progressView.isHidden = false + progressLabel.isHidden = false + progressView.progress = state.progress + progressLabel.text = "\(Int(state.progress * 100))%" + statusLabel.text = state.status + statusLabel.textColor = .label + + backgroundInfoLabel.alpha = 1 + } } // MARK: - Load Images @@ -169,17 +316,17 @@ final class ObjectCapturePreviewController: UIViewController { // Quality check if photoCount < 20 { - statusLabel.text = "⚠️ Low photo count. For best results, capture 30-50 photos" + statusLabel.text = " Low photo count. For best results, capture 30-50 photos" statusLabel.textColor = .systemOrange } else if photoCount > 100 { - statusLabel.text = "✓ Excellent coverage! Ready for high-quality model" + statusLabel.text = " Excellent coverage! Ready for high-quality model" statusLabel.textColor = .systemGreen } else { - statusLabel.text = "✓ Good photo count. Ready to generate" + statusLabel.text = " Good photo count. Ready to generate" statusLabel.textColor = .systemGreen } } catch { - print("❌ Error loading images: \(error)") + print(" Error loading images: \(error)") } } @@ -189,7 +336,9 @@ final class ObjectCapturePreviewController: UIViewController { scrollView.addSubview(contentView) [headerLabel, photoCountLabel, collectionView, statusLabel, - progressView, progressLabel, generateButton, retakeButton, activityIndicator, exportButton].forEach { + qualityLabel, qualitySegment, qualityDescLabel, + progressView, progressLabel, backgroundInfoLabel, + generateButton, retakeButton, activityIndicator, exportButton].forEach { contentView.addSubview($0) } @@ -216,19 +365,35 @@ final class ObjectCapturePreviewController: UIViewController { collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), collectionView.heightAnchor.constraint(equalToConstant: 100), - statusLabel.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 30), + statusLabel.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 24), statusLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), statusLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - progressView.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 16), + // Quality selector + qualityLabel.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 24), + qualityLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + qualitySegment.topAnchor.constraint(equalTo: qualityLabel.bottomAnchor, constant: 8), + qualitySegment.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + qualitySegment.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + qualityDescLabel.topAnchor.constraint(equalTo: qualitySegment.bottomAnchor, constant: 8), + qualityDescLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + qualityDescLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + progressView.topAnchor.constraint(equalTo: qualityDescLabel.bottomAnchor, constant: 20), progressView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), progressView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), progressView.heightAnchor.constraint(equalToConstant: 8), progressLabel.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: 8), progressLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + backgroundInfoLabel.topAnchor.constraint(equalTo: progressLabel.bottomAnchor, constant: 12), + backgroundInfoLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + backgroundInfoLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - generateButton.topAnchor.constraint(equalTo: progressLabel.bottomAnchor, constant: 30), + generateButton.topAnchor.constraint(equalTo: backgroundInfoLabel.bottomAnchor, constant: 20), generateButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), generateButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), generateButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), @@ -240,7 +405,7 @@ final class ObjectCapturePreviewController: UIViewController { exportButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), exportButton.heightAnchor.constraint(equalToConstant: 50), - retakeButton.topAnchor.constraint(equalTo: generateButton.bottomAnchor, constant: 12), + retakeButton.topAnchor.constraint(equalTo: exportButton.bottomAnchor, constant: 12), retakeButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), retakeButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), retakeButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), @@ -286,6 +451,7 @@ final class ObjectCapturePreviewController: UIViewController { generateButton.isEnabled = false retakeButton.isEnabled = false + qualitySegment.isEnabled = false activityIndicator.startAnimating() statusLabel.text = "🔄 Preparing photogrammetry session..." statusLabel.textColor = .label @@ -295,188 +461,88 @@ final class ObjectCapturePreviewController: UIViewController { progressView.progress = 0 progressLabel.text = "0%" + // Show cancel button and background info with animation + UIView.animate(withDuration: 0.3) { + self.backgroundInfoLabel.alpha = 1 + self.cancelButton.isHidden = false + } + // Haptic feedback let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred() - // Generate filename with timestamp - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm" - let timestamp = dateFormatter.string(from: Date()) - - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent("Furniture_\(timestamp).usdz") - - // Optimized configuration - var config = PhotogrammetrySession.Configuration() - config.sampleOrdering = .sequential - config.featureSensitivity = .normal - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in + // Use the background processor for faster, background-capable processing + processor.startProcessing( + imagesFolder: imagesFolder, + detailLevel: selectedDetailLevel + ) { [weak self] result in guard let self = self else { return } - print("🔍 Starting photogrammetry with \(self.photoCount) images") - print("📁 Input folder: \(self.imagesFolder.path)") - print("💾 Output: \(outputURL.path)") - - guard let session = try? PhotogrammetrySession( - input: self.imagesFolder, - configuration: config - ) else { - DispatchQueue.main.async { - self.handleError(message: "Failed to create photogrammetry session") - } - return - } - - let request = PhotogrammetrySession.Request.modelFile(url: outputURL) - var startTime = Date() - - Task { - do { - for try await output in session.outputs { - switch output { - - - - case .processingComplete: - print("✅ Processing complete!") - - case .inputComplete: - print("📥 Input complete") - - case .requestProgress(let request, let fraction): - let percentage = Int(fraction * 100) - - DispatchQueue.main.async { - self.progressView.setProgress(Float(fraction), animated: true) - self.progressLabel.text = "\(percentage)%" - - if percentage < 30 { - self.statusLabel.text = "🔍 Analyzing images..." - } else if percentage < 70 { - self.statusLabel.text = "🏗️ Building 3D mesh..." - } else if percentage < 95 { - self.statusLabel.text = "🎨 Applying textures..." - } else { - self.statusLabel.text = "✨ Finalizing model..." - } - - print("📊 Progress: \(percentage)%") - } - - case .requestComplete(let request, let result): - let elapsed = Date().timeIntervalSince(startTime) - print("✅ Request complete in \(String(format: "%.1f", elapsed))s") - - DispatchQueue.main.async { - self.progressView.progress = 1.0 - self.progressLabel.text = "100%" - self.statusLabel.text = "✓ 3D Model Generated!" - self.statusLabel.textColor = .systemGreen - self.saveModel(outputURL) - } - - case .requestError(let request, let error): - print("❌ Request error: \(error)") - DispatchQueue.main.async { - self.handleError(message: "Processing failed: \(error.localizedDescription)") - } - - case .processingCancelled: - print("⚠️ Processing cancelled") - DispatchQueue.main.async { - self.handleError(message: "Processing was cancelled") - } - - case .invalidSample(let id, let reason): - print("⚠️ Invalid sample \(id): \(reason)") - - case .skippedSample(let id): - print("⏭️ Skipped sample: \(id)") - - case .automaticDownsampling: - print("📉 Automatic downsampling applied") - default: - print("⚠️ Unknown output: \(output)") - } - } - } catch { - print("❌ Task error: \(error)") - DispatchQueue.main.async { - self.handleError(message: "An error occurred: \(error.localizedDescription)") - } - } + switch result { + case .success(let savedURL): + self.handleProcessingComplete(savedURL: savedURL) + + case .failure(let error): + self.handleError(message: error.localizedDescription) } - - do { - try session.process(requests: [request]) - } catch { - print("❌ Process error: \(error)") - DispatchQueue.main.async { - self.handleError(message: "Failed to start processing: \(error.localizedDescription)") - } + } + } + + // MARK: - Processing Complete Handler + private func handleProcessingComplete(savedURL: URL) { + activityIndicator.stopAnimating() + isProcessing = false + cancelButton.isHidden = true + + progressView.progress = 1.0 + progressLabel.text = "100%" + statusLabel.text = " Model Saved Successfully!" + statusLabel.textColor = .systemGreen + + // Success haptic + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + + // Animate success + UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.8) { + self.generateButton.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) + self.generateButton.backgroundColor = .systemGreen + } completion: { _ in + UIView.animate(withDuration: 0.3) { + self.generateButton.transform = .identity } } + + // Update button + generateButton.setTitle("View in My Models", for: .normal) + generateButton.isEnabled = true + qualitySegment.isEnabled = true + + print(" Model saved to: \(savedURL.path)") + + // Auto-navigate after 1.5 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.navigationController?.popToRootViewController(animated: true) + } } private func handleError(message: String) { isProcessing = false activityIndicator.stopAnimating() - statusLabel.text = "❌ \(message)" + cancelButton.isHidden = true + statusLabel.text = " \(message)" statusLabel.textColor = .systemRed generateButton.isEnabled = true retakeButton.isEnabled = true + qualitySegment.isEnabled = true progressView.isHidden = true progressLabel.isHidden = true + backgroundInfoLabel.alpha = 0 // Error haptic let generator = UINotificationFeedbackGenerator() generator.notificationOccurred(.error) } - - private func saveModel(_ url: URL) { - SaveManager.shared.saveModel(from: url, type: .furniture, customName: nil) { [weak self] result in - guard let self = self else { return } - - self.activityIndicator.stopAnimating() - self.isProcessing = false - - switch result { - case .success(let savedURL): - self.statusLabel.text = "✓ Model Saved Successfully!" - self.statusLabel.textColor = .systemGreen - - // Success haptic - let generator = UINotificationFeedbackGenerator() - generator.notificationOccurred(.success) - - // Animate success - UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.8) { - self.generateButton.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) - self.generateButton.backgroundColor = .systemGreen - } completion: { _ in - UIView.animate(withDuration: 0.3) { - self.generateButton.transform = .identity - } - } - - // Update button - self.generateButton.setTitle("View in My Models", for: .normal) - self.generateButton.isEnabled = true - - print("✅ Model saved to: \(savedURL.path)") - - // Auto-navigate after 1.5 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.navigationController?.popToRootViewController(animated: true) - } - - case .failure(let error): - self.handleError(message: "Failed to save: \(error.localizedDescription)") - } - } - } } // MARK: - Collection View diff --git a/Envision/Screens/MainTabs/furniture/Object Capture/ObjectScanViewController.swift b/Envision/Screens/MainTabs/furniture/Object Capture/ObjectScanViewController.swift index 93e8f09..be95a06 100644 --- a/Envision/Screens/MainTabs/furniture/Object Capture/ObjectScanViewController.swift +++ b/Envision/Screens/MainTabs/furniture/Object Capture/ObjectScanViewController.swift @@ -304,8 +304,11 @@ Move the phone slowly around the object // MARK: - Auto Capture private func startAutoCapture() { - captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - self.takePhoto() + // Capture every 0.4 seconds for good overlap between photos + // Faster capture = more photos = better reconstruction but longer processing + // 0.4s is a good balance for walking pace around object + captureTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { [weak self] _ in + self?.takePhoto() } } @@ -319,7 +322,7 @@ Move the phone slowly around the object let count = images.count if count < 20 { - qualityIndicator.text = "⚠️ Keep capturing" + qualityIndicator.text = " Keep capturing" qualityIndicator.textColor = .systemYellow qualityIndicator.backgroundColor = UIColor.systemYellow.withAlphaComponent(0.2) stopButton.isEnabled = false @@ -332,17 +335,17 @@ Move the phone slowly around the object stopButton.alpha = 1.0 guidanceLabel.text = "Good! Continue for better quality" } else if count < 50 { - qualityIndicator.text = "✓✓ Good coverage" + qualityIndicator.text = " Good coverage" qualityIndicator.textColor = .systemGreen qualityIndicator.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.3) guidanceLabel.text = "Excellent! Almost there..." } else if count < 80 { - qualityIndicator.text = "✓✓✓ Excellent!" + qualityIndicator.text = " Excellent!" qualityIndicator.textColor = .systemTeal qualityIndicator.backgroundColor = UIColor.systemTeal.withAlphaComponent(0.3) guidanceLabel.text = "Perfect coverage! Tap Finish" } else { - qualityIndicator.text = "🏆 Maximum coverage" + qualityIndicator.text = " Maximum coverage" qualityIndicator.textColor = .systemPurple qualityIndicator.backgroundColor = UIColor.systemPurple.withAlphaComponent(0.3) guidanceLabel.text = "Outstanding! Ready to process" @@ -357,7 +360,7 @@ Move the phone slowly around the object // Calculate capture duration if let startTime = captureStartTime { let duration = Date().timeIntervalSince(startTime) - print("📸 Capture completed:") + print(" Capture completed:") print(" • Photos: \(images.count)") print(" • Duration: \(String(format: "%.1f", duration))s") print(" • Average: \(String(format: "%.1f", duration / Double(images.count)))s per photo") diff --git a/Envision/Screens/MainTabs/furniture/ScanFurnitureViewController.swift b/Envision/Screens/MainTabs/furniture/ScanFurnitureViewController.swift index 65597b0..9e89639 100644 --- a/Envision/Screens/MainTabs/furniture/ScanFurnitureViewController.swift +++ b/Envision/Screens/MainTabs/furniture/ScanFurnitureViewController.swift @@ -1089,3 +1089,4 @@ final class FurnitureChipCell: UICollectionViewCell { button.setAttributedTitle(attributedString, for: .normal) } } + diff --git a/Envision/Screens/MainTabs/furniture/roomPlanColor/RoomARWithFurnitureViewController.swift b/Envision/Screens/MainTabs/furniture/roomPlanColor/RoomARWithFurnitureViewController.swift index 020571c..1d38f7f 100644 --- a/Envision/Screens/MainTabs/furniture/roomPlanColor/RoomARWithFurnitureViewController.swift +++ b/Envision/Screens/MainTabs/furniture/roomPlanColor/RoomARWithFurnitureViewController.swift @@ -150,9 +150,14 @@ func attachLabel(to entity: Entity, text: String, yOffset: Float = 0.1) { let textEntity = ModelEntity(mesh: mesh, materials: [material]) textEntity.position = [0, yOffset, 0] - textEntity.components.set(BillboardComponent()) - + entity.addChild(textEntity) + + // Set BillboardComponent after a brief delay to avoid crash + DispatchQueue.main.async { [weak textEntity] in + guard let textEntity = textEntity, textEntity.parent != nil else { return } + textEntity.components.set(BillboardComponent()) + } } // Replacement logic @@ -165,7 +170,7 @@ func replaceEntities(prefix: String, in root: Entity, with modelName: String, sc let originalTransform = entity.transformMatrix(relativeTo: parent) guard let newModel = try? Entity.load(named: modelName) else { - print("❌ Failed to load:", modelName) + print(" Failed to load:", modelName) return } diff --git a/Envision/Screens/MainTabs/furniture/roomPlanColor/VisualizeRoomViewController.swift b/Envision/Screens/MainTabs/furniture/roomPlanColor/VisualizeRoomViewController.swift index 2c52004..a67bd75 100644 --- a/Envision/Screens/MainTabs/furniture/roomPlanColor/VisualizeRoomViewController.swift +++ b/Envision/Screens/MainTabs/furniture/roomPlanColor/VisualizeRoomViewController.swift @@ -114,7 +114,41 @@ class VisualizeRoomViewController: UIViewController, UIDocumentPickerDelegate { let picker = UIColorPickerViewController() picker.delegate = self picker.supportsAlpha = true - present(picker, animated: true) + + // Wrap in navigation controller to add custom buttons + let navController = UINavigationController(rootViewController: picker) + navController.modalPresentationStyle = .pageSheet + + // Add native Cancel button (left side) + let cancelButton = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelColorPicker) + ) + picker.navigationItem.leftBarButtonItem = cancelButton + + // Add native Done button (right side) + let doneButton = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissColorPicker) + ) + picker.navigationItem.rightBarButtonItem = doneButton + picker.navigationItem.title = "Choose Color" + + present(navController, animated: true) + } + + @objc private func cancelColorPicker() { + // Restore original materials when cancelling + if let model = selectedModel, let original = originalMaterials[model] { + model.model?.materials = original + } + dismiss(animated: true) + } + + @objc private func dismissColorPicker() { + dismiss(animated: true) } // ---------------------------------------------------------- @@ -148,7 +182,7 @@ class VisualizeRoomViewController: UIViewController, UIDocumentPickerDelegate { let entity = try Entity.load(contentsOf: url) placeModel(entity) } catch { - print("❌ Failed to load model:", error) + print(" Failed to load model:", error) } } @@ -218,18 +252,41 @@ class VisualizeRoomViewController: UIViewController, UIDocumentPickerDelegate { // MARK: - Labels // ---------------------------------------------------------- func attachLabel(to entity: Entity, text: String, yOffset: Float) { - - labelStorage[entity]?.removeFromParent() + // Remove existing label safely + if let existingLabel = labelStorage[entity] { + existingLabel.components.remove(BillboardComponent.self) + existingLabel.removeFromParent() + labelStorage[entity] = nil + } let mesh = MeshResource.generateText(text, extrusionDepth: 0.01, font: .systemFont(ofSize: 0.15)) let labelEntity = ModelEntity(mesh: mesh, materials: [SimpleMaterial(color: .white, isMetallic: false)]) labelEntity.position = [0, yOffset, 0] - labelEntity.components.set(BillboardComponent()) labelEntity.isEnabled = showLabels entity.addChild(labelEntity) labelStorage[entity] = labelEntity + + // Set BillboardComponent after a brief delay to avoid crash + DispatchQueue.main.async { [weak labelEntity] in + guard let labelEntity = labelEntity, labelEntity.parent != nil else { return } + labelEntity.components.set(BillboardComponent()) + } + } + + // MARK: - Cleanup + private func cleanupLabels() { + for (_, label) in labelStorage { + label.components.remove(BillboardComponent.self) + label.removeFromParent() + } + labelStorage.removeAll() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cleanupLabels() } } diff --git a/Envision/Screens/MainTabs/profile/ProfileViewController.swift b/Envision/Screens/MainTabs/profile/ProfileViewController.swift index dd4074c..e9f13c5 100644 --- a/Envision/Screens/MainTabs/profile/ProfileViewController.swift +++ b/Envision/Screens/MainTabs/profile/ProfileViewController.swift @@ -50,6 +50,8 @@ class ProfileViewController: UIViewController { // ("key.fill", "Security", false), ], .about: [ + ("lightbulb.fill", "Tips & Tutorials", false), + ("arrow.counterclockwise", "Restart App Tour", false), ("info.circle.fill", "App Info", false), ("doc.text.fill", "Terms of Service", false), ("shield.lefthalf.filled", "Privacy Policy", false), @@ -268,6 +270,12 @@ extension ProfileViewController: UITableViewDelegate, UITableViewDataSource { case (.privacy, "Permissions"): navigationController?.pushViewController(PermissionsViewController(), animated: true) + + case (.about, "Tips & Tutorials"): + navigationController?.pushViewController(TipsLibraryViewController(), animated: true) + + case (.about, "Restart App Tour"): + handleRestartTour() case (.about, "App Info"): navigationController?.pushViewController(AppInfoViewController(), animated: true) @@ -287,4 +295,23 @@ extension ProfileViewController: UITableViewDelegate, UITableViewDataSource { print("Tapped:", item.title) } + + private func handleRestartTour() { + let alert = UIAlertController( + title: "Restart App Tour?", + message: "This will reset all tips and guided tours.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Restart", style: .default) { _ in + TourManager.shared.resetTour() + + let confirm = UIAlertController(title: "Tour Reset", message: "The app tour will appear again as you navigate.", preferredStyle: .alert) + confirm.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(confirm, animated: true) + }) + + present(alert, animated: true) + } } diff --git a/Envision/Screens/MainTabs/profile/SubScreens/TipsLibraryViewController.swift b/Envision/Screens/MainTabs/profile/SubScreens/TipsLibraryViewController.swift new file mode 100644 index 0000000..56cea86 --- /dev/null +++ b/Envision/Screens/MainTabs/profile/SubScreens/TipsLibraryViewController.swift @@ -0,0 +1,182 @@ +// +// TipsLibraryViewController.swift +// Envision +// + +import UIKit + +final class TipsLibraryViewController: UIViewController { + + // MARK: - Data + struct TipItem { + let title: String + let message: String + let icon: String + let color: UIColor + } + + struct TipSection { + let title: String + let items: [TipItem] + } + + private var sections: [TipSection] = [] + + // MARK: - UI + private let tableView: UITableView = { + let tv = UITableView(frame: .zero, style: .insetGrouped) + tv.translatesAutoresizingMaskIntoConstraints = false + tv.register(TipLibraryCell.self, forCellReuseIdentifier: "TipCell") + return tv + }() + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + title = "Tips & Tutorials" + view.backgroundColor = .systemGroupedBackground + + setupData() + setupUI() + } + + private func setupData() { + // Prepare static data matching the real tips + // This allows viewing tips regardless of TipKit state rules + + sections = [ + TipSection(title: "Getting Started", items: [ + TipItem(title: "Welcome to EnVision", message: "Take a tour to learn how to scan rooms and furniture.", icon: "hand.wave.fill", color: .systemBlue), + TipItem(title: "Customize Your Profile", message: "Set your preferences and personalize your experience.", icon: "person.crop.circle", color: .systemOrange) + ]), + + TipSection(title: "Room Scanning", items: [ + TipItem(title: "Scan Your First Room", message: "Use the green camera button to start scanning with LiDAR.", icon: "camera.viewfinder", color: .systemGreen), + TipItem(title: "Scan Slowly", message: "Move your device slowly for the best accuracy.", icon: "tortoise.fill", color: .systemTeal), + TipItem(title: "Import Models", message: "Bring in existing USDZ models from Files.", icon: "square.and.arrow.down", color: .systemIndigo) + ]), + + TipSection(title: "Furniture Capture", items: [ + TipItem(title: "Capture Furniture", message: "Scan objects using 360° photogrammetry.", icon: "camera.metering.center.weighted", color: .systemPurple), + TipItem(title: "Good Lighting", message: "Ensure even lighting and avoid harsh shadows.", icon: "sun.max.fill", color: .systemYellow), + TipItem(title: "360° Coverage", message: "Walk around the entire object to capture all angles.", icon: "arrow.triangle.2.circlepath.camera", color: .systemRed) + ]), + + TipSection(title: "Organization", items: [ + TipItem(title: "Categories", message: "Organize your rooms and furniture with smart categories.", icon: "tag.fill", color: .systemPink), + TipItem(title: "Actions Menu", message: "Select, delete, or share multiple items at once.", icon: "ellipsis.circle", color: .systemGray) + ]) + ] + } + + private func setupUI() { + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + tableView.dataSource = self + tableView.delegate = self + } +} + +// MARK: - UITableViewDataSource +extension TipsLibraryViewController: UITableViewDataSource, UITableViewDelegate { + + func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].items.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return sections[section].title + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: "TipCell", for: indexPath) as? TipLibraryCell else { + return UITableViewCell() + } + + let item = sections[indexPath.section].items[indexPath.row] + cell.configure(item: item) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + // Could expand or show detail VC if needed + } +} + +// MARK: - Cell +final class TipLibraryCell: UITableViewCell { + + private let iconContainer = UIView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let messageLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + iconContainer.translatesAutoresizingMaskIntoConstraints = false + iconContainer.layer.cornerRadius = 8 + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.contentMode = .scaleAspectFit + iconView.tintColor = .white + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + + messageLabel.translatesAutoresizingMaskIntoConstraints = false + messageLabel.font = .systemFont(ofSize: 14) + messageLabel.textColor = .secondaryLabel + messageLabel.numberOfLines = 0 + + iconContainer.addSubview(iconView) + contentView.addSubview(iconContainer) + contentView.addSubview(titleLabel) + contentView.addSubview(messageLabel) + + NSLayoutConstraint.activate([ + iconContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + iconContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + iconContainer.widthAnchor.constraint(equalToConstant: 32), + iconContainer.heightAnchor.constraint(equalToConstant: 32), + + iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 20), + iconView.heightAnchor.constraint(equalToConstant: 20), + + titleLabel.topAnchor.constraint(equalTo: iconContainer.topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 12), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + messageLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + messageLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + messageLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12) + ]) + } + + func configure(item: TipsLibraryViewController.TipItem) { + titleLabel.text = item.title + messageLabel.text = item.message + iconView.image = UIImage(systemName: item.icon) + iconContainer.backgroundColor = item.color + } +} diff --git a/Envision/Screens/Onboarding/OnboardingController.swift b/Envision/Screens/Onboarding/OnboardingController.swift index 4f1b167..c40a395 100644 --- a/Envision/Screens/Onboarding/OnboardingController.swift +++ b/Envision/Screens/Onboarding/OnboardingController.swift @@ -2,8 +2,6 @@ // OnboardingController.swift // Envisionf2 // -// Created by Abishai on 15/11/25. -// import UIKit diff --git a/Envision/Tips/AppTips.swift b/Envision/Tips/AppTips.swift new file mode 100644 index 0000000..30df18f --- /dev/null +++ b/Envision/Tips/AppTips.swift @@ -0,0 +1,11 @@ +// +// AppTips.swift +// Envision +// +// Tips are temporarily removed from the runtime app flow. +// This file is kept as a placeholder so the project structure remains intact. +// + +import Foundation + +// Tip definitions removed for now. diff --git a/Envision/Tips/TipPresenter.swift b/Envision/Tips/TipPresenter.swift new file mode 100644 index 0000000..186f292 --- /dev/null +++ b/Envision/Tips/TipPresenter.swift @@ -0,0 +1,251 @@ +import UIKit + +/// Pure UIKit tip presenter (no SwiftUI/UIHostingController). +/// +/// This renders a lightweight banner that matches iOS styling (blur + rounded corners), +/// supports multiple buttons, and never installs full-screen overlays. +/// +/// Note: The app-wide tips/tour integration is temporarily disabled. +final class TipPresenter { + + // MARK: - Types + + struct Action { + let id: String + let title: String + } + + // MARK: - Private + + private weak var owner: UIViewController? + private weak var containerView: UIView? + + private var bannerView: TipBannerView? + private var heightConstraint: NSLayoutConstraint? + + init(owner: UIViewController, containerView: UIView) { + self.owner = owner + self.containerView = containerView + } + + func dismiss() { + bannerView?.removeFromSuperview() + bannerView = nil + + heightConstraint?.isActive = false + heightConstraint = nil + + containerView?.isHidden = true + owner?.view.setNeedsLayout() + } + + /// Presents a UIKit banner representing the provided Tip. + /// Note: We intentionally don't attempt to mirror TipKit's rule evaluation UI. + /// The calling screen decides *which* tip to show; this class only renders it. + @discardableResult + func present( + title: String, + message: String?, + systemImageName: String?, + actions: [Action], + onAction: @escaping (String) -> Void + ) -> CGFloat { + guard let owner, let containerView else { return 0 } + + dismiss() + + let banner = TipBannerView() + banner.translatesAutoresizingMaskIntoConstraints = false + banner.configure( + title: title, + message: message, + systemImageName: systemImageName, + actions: actions, + onAction: onAction + ) + + containerView.addSubview(banner) + NSLayoutConstraint.activate([ + banner.topAnchor.constraint(equalTo: containerView.topAnchor), + banner.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + banner.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + banner.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + ]) + + bannerView = banner + containerView.isHidden = false + + owner.view.layoutIfNeeded() + + // Measure via Auto Layout and lock height to keep layout stable. + let width = max(1, containerView.bounds.width) + let measured = containerView.systemLayoutSizeFitting( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + + heightConstraint?.isActive = false + let hc = containerView.heightAnchor.constraint(equalToConstant: max(0, measured)) + hc.priority = .required + hc.isActive = true + heightConstraint = hc + + owner.view.layoutIfNeeded() + return measured + } +} + +// MARK: - TipBannerView + +private final class TipBannerView: UIView { + + private let backgroundView: UIVisualEffectView = { + let v = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + v.translatesAutoresizingMaskIntoConstraints = false + v.clipsToBounds = true + v.layer.cornerRadius = 16 + return v + }() + + private let iconView: UIImageView = { + let iv = UIImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.contentMode = .scaleAspectFit + iv.tintColor = .label + return iv + }() + + private let titleLabel: UILabel = { + let l = UILabel() + l.translatesAutoresizingMaskIntoConstraints = false + l.font = .systemFont(ofSize: 16, weight: .semibold) + l.textColor = .label + l.numberOfLines = 0 + return l + }() + + private let messageLabel: UILabel = { + let l = UILabel() + l.translatesAutoresizingMaskIntoConstraints = false + l.font = .systemFont(ofSize: 14) + l.textColor = .secondaryLabel + l.numberOfLines = 0 + return l + }() + + private let buttonStack: UIStackView = { + let s = UIStackView() + s.translatesAutoresizingMaskIntoConstraints = false + s.axis = .horizontal + s.alignment = .fill + s.distribution = .fillEqually + s.spacing = 10 + return s + }() + + private var actionHandler: ((String) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + translatesAutoresizingMaskIntoConstraints = false + isUserInteractionEnabled = true + + addSubview(backgroundView) + + NSLayoutConstraint.activate([ + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + let content = backgroundView.contentView + + content.addSubview(iconView) + content.addSubview(titleLabel) + content.addSubview(messageLabel) + content.addSubview(buttonStack) + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 14), + iconView.topAnchor.constraint(equalTo: content.topAnchor, constant: 14), + iconView.widthAnchor.constraint(equalToConstant: 22), + iconView.heightAnchor.constraint(equalToConstant: 22), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10), + titleLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -14), + titleLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 14), + + messageLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + messageLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + + buttonStack.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + buttonStack.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + buttonStack.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 12), + buttonStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -14), + buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: 34) + ]) + } + + func configure( + title: String, + message: String?, + systemImageName: String?, + actions: [TipPresenter.Action], + onAction: @escaping (String) -> Void + ) { + self.actionHandler = onAction + + titleLabel.text = title + messageLabel.text = message + messageLabel.isHidden = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + + if let systemImageName { + iconView.image = UIImage(systemName: systemImageName) + iconView.isHidden = false + } else { + iconView.isHidden = true + } + + // Rebuild buttons + buttonStack.arrangedSubviews.forEach { v in + buttonStack.removeArrangedSubview(v) + v.removeFromSuperview() + } + + for action in actions { + let b = UIButton(type: .system) + b.translatesAutoresizingMaskIntoConstraints = false + + var config = UIButton.Configuration.filled() + config.title = action.title + config.baseBackgroundColor = .tertiarySystemFill + config.baseForegroundColor = .label + config.cornerStyle = .medium + config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) + b.configuration = config + + b.accessibilityIdentifier = action.id + b.addTarget(self, action: #selector(actionTapped(_:)), for: .touchUpInside) + buttonStack.addArrangedSubview(b) + } + + // If no actions, collapse the stack. + buttonStack.isHidden = actions.isEmpty + } + + @objc private func actionTapped(_ sender: UIButton) { + guard let id = sender.accessibilityIdentifier else { return } + actionHandler?(id) + } +} diff --git a/FIREBASE_BACKEND_IMPLEMENTATION_PLAN.md b/FIREBASE_BACKEND_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..171d0d1 --- /dev/null +++ b/FIREBASE_BACKEND_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1489 @@ +# EnVision - Firebase Backend Implementation Plan +*Comprehensive Guide for Backend Integration* + +--- + +## Table of Contents +1. [Overview](#1-overview) +2. [Firebase Setup](#2-firebase-setup) +3. [Authentication System](#3-authentication-system) +4. [Firestore Database Design](#4-firestore-database-design) +5. [Firebase Storage Structure](#5-firebase-storage-structure) +6. [Code Implementation](#6-code-implementation) +7. [Security Rules](#7-security-rules) +8. [Migration Strategy](#8-migration-strategy) +9. [Testing Plan](#9-testing-plan) +10. [Deployment Checklist](#10-deployment-checklist) + +--- + +## 1. Overview + +### 1.1 Goals +- **User Authentication**: Email/password login with password reset +- **Cloud Sync**: Sync user data, rooms, and furniture across devices +- **Cloud Storage**: Store profile pictures and optionally USDZ files +- **Offline Support**: Cache data locally for offline access +- **Security**: User data isolated with Firestore security rules + +### 1.2 Firebase Services to Use +- **Firebase Authentication**: Email/Password, (future: Apple Sign-In, Google Sign-In) +- **Cloud Firestore**: NoSQL database for user profiles, rooms, furniture metadata +- **Firebase Storage**: Blob storage for images and 3D models +- **Firebase Analytics** (optional): Track user engagement +- **Crashlytics** (optional): Monitor app stability + +### 1.3 Architecture Overview +``` +iOS App (UIKit) + ↓ +FirebaseManager (Singleton) + ├── AuthManager (Firebase Auth) + ├── FirestoreManager (Firestore CRUD) + └── StorageManager (Firebase Storage) + ↓ +Local Cache (UserDefaults + FileManager) + ↓ +UI Updates (via completion handlers / delegates) +``` + +--- + +## 2. Firebase Setup + +### 2.1 Create Firebase Project + +**Steps**: +1. Go to [Firebase Console](https://console.firebase.google.com/) +2. Click **"Add project"** +3. Name: `EnVision-Production` (or your preferred name) +4. Enable Google Analytics (optional but recommended) +5. Create project (takes ~30 seconds) + +### 2.2 Add iOS App to Firebase + +**Steps**: +1. In Firebase Console, click **"Add app"** → iOS +2. **iOS bundle ID**: `com.yourcompany.EnVision` (must match Xcode project) +3. **App nickname**: `EnVision iOS` +4. **App Store ID**: (leave blank for now, add later) +5. Download `GoogleService-Info.plist` +6. **Important**: Add `GoogleService-Info.plist` to Xcode: + - Drag into Xcode project navigator + - **Ensure "Copy items if needed" is checked** + - **Target membership**: Envision ✅ + +### 2.3 Add Firebase SDK via Swift Package Manager + +**Steps**: +1. In Xcode: **File → Add Package Dependencies...** +2. URL: `https://github.com/firebase/firebase-ios-sdk` +3. Version: **10.20.0** or latest +4. Select packages to add: + - ✅ `FirebaseAuth` + - ✅ `FirebaseFirestore` + - ✅ `FirebaseStorage` + - ✅ `FirebaseAnalytics` (optional) + - ✅ `FirebaseCrashlytics` (optional) +5. Click **"Add Package"** +6. Wait for SPM to resolve dependencies (~2-3 minutes) + +### 2.4 Configure Firebase in AppDelegate + +**File**: `Envision/AppDelegate.swift` + +```swift +import UIKit +import FirebaseCore + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + // Configure Firebase FIRST (before any Firebase calls) + FirebaseApp.configure() + print("✅ Firebase configured successfully") + + // ... existing theme setup code ... + + return true + } +} +``` + +### 2.5 Verify Installation + +**Quick Test** (add to `SceneDelegate.willConnectTo` temporarily): +```swift +import FirebaseAuth + +// In willConnectTo, after window setup +if let currentUser = Auth.auth().currentUser { + print("✅ Firebase Auth works! User: \(currentUser.uid)") +} else { + print("✅ Firebase Auth works! No user logged in") +} +``` + +--- + +## 3. Authentication System + +### 3.1 Create AuthManager + +**File**: `Envision/Managers/AuthManager.swift` + +```swift +import Foundation +import FirebaseAuth + +final class AuthManager { + static let shared = AuthManager() + + private init() {} + + // MARK: - Current User + + var currentUser: User? { + return Auth.auth().currentUser + } + + var isLoggedIn: Bool { + return currentUser != nil + } + + var currentUserID: String? { + return currentUser?.uid + } + + // MARK: - Sign Up + + func signUp(email: String, password: String, name: String, completion: @escaping (Result) -> Void) { + Auth.auth().createUser(withEmail: email, password: password) { [weak self] result, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let user = result?.user else { + completion(.failure(NSError(domain: "AuthManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "User creation failed"]))) + return + } + + // Update display name + let changeRequest = user.createProfileChangeRequest() + changeRequest.displayName = name + changeRequest.commitChanges { error in + if let error = error { + print("⚠️ Failed to set display name: \(error)") + } + } + + // Create user document in Firestore + FirestoreManager.shared.createUserDocument(uid: user.uid, email: email, name: name) { result in + switch result { + case .success: + completion(.success(user)) + case .failure(let error): + print("⚠️ Failed to create user document: \(error)") + // Still return success (user created, just doc failed) + completion(.success(user)) + } + } + } + } + + // MARK: - Sign In + + func signIn(email: String, password: String, completion: @escaping (Result) -> Void) { + Auth.auth().signIn(withEmail: email, password: password) { result, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let user = result?.user else { + completion(.failure(NSError(domain: "AuthManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Sign in failed"]))) + return + } + + completion(.success(user)) + } + } + + // MARK: - Sign Out + + func signOut(completion: @escaping (Result) -> Void) { + do { + try Auth.auth().signOut() + + // Clear local user data + UserManager.shared.logout() + + completion(.success(())) + } catch { + completion(.failure(error)) + } + } + + // MARK: - Password Reset + + func sendPasswordReset(email: String, completion: @escaping (Result) -> Void) { + Auth.auth().sendPasswordReset(withEmail: email) { error in + if let error = error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + } + + // MARK: - Delete Account + + func deleteAccount(completion: @escaping (Result) -> Void) { + guard let user = currentUser else { + completion(.failure(NSError(domain: "AuthManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No user logged in"]))) + return + } + + let uid = user.uid + + // Delete Firestore data first + FirestoreManager.shared.deleteUserData(uid: uid) { result in + switch result { + case .success: + // Then delete auth account + user.delete { error in + if let error = error { + completion(.failure(error)) + } else { + UserManager.shared.logout() + completion(.success(())) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - Re-authentication (for sensitive operations) + + func reauthenticate(password: String, completion: @escaping (Result) -> Void) { + guard let user = currentUser, let email = user.email else { + completion(.failure(NSError(domain: "AuthManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No user logged in"]))) + return + } + + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + + user.reauthenticate(with: credential) { _, error in + if let error = error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + } +} +``` + +### 3.2 Update UserManager to Use Firebase + +**File**: `Envision/Extensions/UserManager.swift` + +```swift +import Foundation +import FirebaseAuth + +final class UserManager { + static let shared = UserManager() + + // MARK: - Local Cache (for offline access) + + var currentUser: UserModel? { + get { + guard let data = UserDefaults.standard.data(forKey: "currentUser"), + let user = try? JSONDecoder().decode(UserModel.self, from: data) else { + return nil + } + return user + } + set { + if let user = newValue { + let data = try? JSONEncoder().encode(user) + UserDefaults.standard.set(data, forKey: "currentUser") + } else { + UserDefaults.standard.removeObject(forKey: "currentUser") + } + } + } + + var isLoggedIn: Bool { + return AuthManager.shared.isLoggedIn && currentUser != nil + } + + // MARK: - Login (Firebase) + + func login(email: String, password: String, completion: @escaping (Bool) -> Void) { + AuthManager.shared.signIn(email: email, password: password) { [weak self] result in + switch result { + case .success(let user): + // Fetch user profile from Firestore + FirestoreManager.shared.fetchUserDocument(uid: user.uid) { fetchResult in + switch fetchResult { + case .success(let userModel): + self?.currentUser = userModel + completion(true) + case .failure(let error): + print("⚠️ Failed to fetch user profile: \(error)") + // Create minimal local user + let userModel = UserModel( + id: user.uid, + name: user.displayName ?? "User", + email: user.email ?? email, + createdAt: Date(), + preferences: UserPreferences() + ) + self?.currentUser = userModel + completion(true) + } + } + case .failure(let error): + print("❌ Login failed: \(error.localizedDescription)") + completion(false) + } + } + } + + // MARK: - Signup (Firebase) + + func signup(name: String, email: String, password: String, completion: @escaping (Bool) -> Void) { + AuthManager.shared.signUp(email: email, password: password, name: name) { [weak self] result in + switch result { + case .success(let user): + // Create local user model + let userModel = UserModel( + id: user.uid, + name: name, + email: email, + createdAt: Date(), + preferences: UserPreferences() + ) + self?.currentUser = userModel + completion(true) + case .failure(let error): + print("❌ Signup failed: \(error.localizedDescription)") + completion(false) + } + } + } + + // MARK: - Logout + + func logout() { + AuthManager.shared.signOut { _ in } + currentUser = nil + + // Clear other local data if needed + TourManager.shared.resetTour() + } + + // MARK: - Update Profile + + func updateProfile(name: String? = nil, bio: String? = nil, completion: @escaping (Bool) -> Void) { + guard var user = currentUser else { + completion(false) + return + } + + if let name = name { + user.name = name + } + if let bio = bio { + user.bio = bio + } + + // Update Firestore + FirestoreManager.shared.updateUserDocument(uid: user.id, data: [ + "name": user.name, + "bio": user.bio ?? "" + ]) { [weak self] result in + switch result { + case .success: + self?.currentUser = user + completion(true) + case .failure(let error): + print("❌ Failed to update profile: \(error)") + completion(false) + } + } + } + + // MARK: - Update Preferences + + func updatePreferences(_ preferences: UserPreferences, completion: @escaping (Bool) -> Void) { + guard var user = currentUser else { + completion(false) + return + } + + user.preferences = preferences + + // Update Firestore + FirestoreManager.shared.updateUserDocument(uid: user.id, data: [ + "preferences": [ + "notificationsEnabled": preferences.notificationsEnabled, + "scanReminders": preferences.scanReminders, + "newFeatureAlerts": preferences.newFeatureAlerts, + "theme": preferences.theme + ] + ]) { [weak self] result in + switch result { + case .success: + self?.currentUser = user + completion(true) + case .failure(let error): + print("❌ Failed to update preferences: \(error)") + completion(false) + } + } + } +} +``` + +### 3.3 Update LoginViewController + +**File**: `Envision/Screens/Onboarding/LoginViewController.swift` + +```swift +// Update handleLogin method + +@objc private func handleLogin() { + guard let email = emailField.text, !email.isEmpty, + let password = passwordField.text, !password.isEmpty else { + showError("Please fill in all fields") + return + } + + guard email.isValidEmail else { + showError("Please enter a valid email address") + return + } + + // Show loading + continueButton.isEnabled = false + continueButton.setTitle("Signing In...", for: .normal) + + // Firebase login + UserManager.shared.login(email: email, password: password) { [weak self] success in + DispatchQueue.main.async { + self?.continueButton.isEnabled = true + self?.continueButton.setTitle("Continue", for: .normal) + + if success { + // Navigate to main app + if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate { + sceneDelegate.switchToMainApp() + } else { + // Fallback + let mainVC = MainTabBarController() + mainVC.modalPresentationStyle = .fullScreen + self?.present(mainVC, animated: true) + } + } else { + self?.showError("Invalid email or password") + } + } + } +} + +private func showError(_ message: String) { + let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) +} +``` + +### 3.4 Update SignupViewController + +**File**: `Envision/Screens/Onboarding/SignupViewController.swift` + +```swift +// Update handleSignup method + +@objc private func handleSignup() { + guard let name = nameField.text, !name.isEmpty, + let email = emailField.text, !email.isEmpty, + let password = passwordField.text, !password.isEmpty, + let confirmPassword = confirmPasswordField.text, !confirmPassword.isEmpty else { + showError("Please fill in all fields") + return + } + + guard email.isValidEmail else { + showError("Please enter a valid email address") + return + } + + guard password.isStrongPassword else { + showError("Password must be at least 8 characters with uppercase, lowercase, and numbers") + return + } + + guard password == confirmPassword else { + showError("Passwords do not match") + return + } + + // Show loading + signupButton.isEnabled = false + signupButton.setTitle("Creating Account...", for: .normal) + + // Firebase signup + UserManager.shared.signup(name: name, email: email, password: password) { [weak self] success in + DispatchQueue.main.async { + self?.signupButton.isEnabled = true + self?.signupButton.setTitle("Create Account", for: .normal) + + if success { + // Navigate to main app + if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate { + sceneDelegate.switchToMainApp() + } else { + // Fallback + let mainVC = MainTabBarController() + mainVC.modalPresentationStyle = .fullScreen + self?.present(mainVC, animated: true) + } + } else { + self?.showError("Failed to create account. Email may already be in use.") + } + } + } +} +``` + +### 3.5 Update ForgotPasswordViewController + +**File**: `Envision/Screens/Onboarding/ForgotPasswordViewController.swift` + +```swift +// Update handleReset method + +@objc private func handleReset() { + guard let email = emailField.text, !email.isEmpty else { + showError("Please enter your email address") + return + } + + guard email.isValidEmail else { + showError("Please enter a valid email address") + return + } + + // Show loading + resetButton.isEnabled = false + resetButton.setTitle("Sending...", for: .normal) + + // Send password reset email + AuthManager.shared.sendPasswordReset(email: email) { [weak self] result in + DispatchQueue.main.async { + self?.resetButton.isEnabled = true + self?.resetButton.setTitle("Send Reset Link", for: .normal) + + switch result { + case .success: + self?.showSuccess("Password reset email sent! Check your inbox.") + case .failure(let error): + self?.showError("Failed to send reset email: \(error.localizedDescription)") + } + } + } +} + +private func showSuccess(_ message: String) { + let alert = UIAlertController(title: "Success", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + }) + present(alert, animated: true) +} +``` + +### 3.6 Add Auto-Login in SplashViewController + +**File**: `Envision/Screens/Onboarding/SplashViewController.swift` + +```swift +import UIKit +import FirebaseAuth + +// Update goNext method + +private func goNext() { + // Check if user is already logged in + if let firebaseUser = Auth.auth().currentUser { + print("✅ User already logged in: \(firebaseUser.uid)") + + // Fetch user profile from Firestore + FirestoreManager.shared.fetchUserDocument(uid: firebaseUser.uid) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let userModel): + UserManager.shared.currentUser = userModel + self?.goToMainApp() + case .failure(let error): + print("⚠️ Failed to fetch user profile: \(error)") + // Still go to main app with minimal user data + let userModel = UserModel( + id: firebaseUser.uid, + name: firebaseUser.displayName ?? "User", + email: firebaseUser.email ?? "", + createdAt: Date(), + preferences: UserPreferences() + ) + UserManager.shared.currentUser = userModel + self?.goToMainApp() + } + } + } + } else { + // No user logged in, show onboarding + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.goToOnboarding() + } + } +} + +private func goToMainApp() { + if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate { + sceneDelegate.switchToMainApp() + } else { + let mainVC = MainTabBarController() + mainVC.modalPresentationStyle = .fullScreen + present(mainVC, animated: true) + } +} + +private func goToOnboarding() { + let onboarding = OnboardingController() + onboarding.modalPresentationStyle = .fullScreen + present(onboarding, animated: true) +} +``` + +--- + +## 4. Firestore Database Design + +### 4.1 Data Model Structure + +``` +firestore/ +├── users/{uid} +│ ├── id: string +│ ├── name: string +│ ├── email: string +│ ├── bio: string +│ ├── profileImageURL: string +│ ├── createdAt: timestamp +│ └── preferences: map +│ ├── notificationsEnabled: boolean +│ ├── scanReminders: boolean +│ ├── newFeatureAlerts: boolean +│ └── theme: number +│ +├── users/{uid}/rooms/{roomId} +│ ├── id: string +│ ├── name: string +│ ├── category: string +│ ├── createdAt: timestamp +│ ├── updatedAt: timestamp +│ ├── usdzURL: string (optional, Firebase Storage path) +│ ├── thumbnailURL: string +│ ├── dimensions: map +│ │ ├── width: number +│ │ ├── length: number +│ │ └── height: number +│ └── notes: string +│ +└── users/{uid}/furniture/{furnitureId} + ├── id: string + ├── name: string + ├── category: string + ├── createdAt: timestamp + ├── updatedAt: timestamp + ├── usdzURL: string (optional) + └── thumbnailURL: string +``` + +### 4.2 Create FirestoreManager + +**File**: `Envision/Managers/FirestoreManager.swift` + +```swift +import Foundation +import FirebaseFirestore + +final class FirestoreManager { + static let shared = FirestoreManager() + + private let db = Firestore.firestore() + + private init() { + // Enable offline persistence + let settings = FirestoreSettings() + settings.isPersistenceEnabled = true + settings.cacheSizeBytes = FirestoreCacheSizeUnlimited + db.settings = settings + } + + // MARK: - User Document + + func createUserDocument(uid: String, email: String, name: String, completion: @escaping (Result) -> Void) { + let userRef = db.collection("users").document(uid) + + let data: [String: Any] = [ + "id": uid, + "name": name, + "email": email, + "bio": "", + "profileImageURL": "", + "createdAt": FieldValue.serverTimestamp(), + "preferences": [ + "notificationsEnabled": true, + "scanReminders": true, + "newFeatureAlerts": true, + "theme": 0 + ] + ] + + userRef.setData(data) { error in + if let error = error { + completion(.failure(error)) + } else { + print("✅ User document created: \(uid)") + completion(.success(())) + } + } + } + + func fetchUserDocument(uid: String, completion: @escaping (Result) -> Void) { + let userRef = db.collection("users").document(uid) + + userRef.getDocument { snapshot, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = snapshot?.data() else { + completion(.failure(NSError(domain: "FirestoreManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "User document not found"]))) + return + } + + // Parse user model + let id = data["id"] as? String ?? uid + let name = data["name"] as? String ?? "User" + let email = data["email"] as? String ?? "" + let bio = data["bio"] as? String + let profileImagePath = data["profileImageURL"] as? String + + let createdAt: Date + if let timestamp = data["createdAt"] as? Timestamp { + createdAt = timestamp.dateValue() + } else { + createdAt = Date() + } + + let prefsData = data["preferences"] as? [String: Any] ?? [:] + let preferences = UserPreferences( + notificationsEnabled: prefsData["notificationsEnabled"] as? Bool ?? true, + scanReminders: prefsData["scanReminders"] as? Bool ?? true, + newFeatureAlerts: prefsData["newFeatureAlerts"] as? Bool ?? true, + theme: prefsData["theme"] as? Int ?? 0 + ) + + let userModel = UserModel( + id: id, + name: name, + email: email, + bio: bio, + profileImagePath: profileImagePath, + createdAt: createdAt, + preferences: preferences + ) + + completion(.success(userModel)) + } + } + + func updateUserDocument(uid: String, data: [String: Any], completion: @escaping (Result) -> Void) { + let userRef = db.collection("users").document(uid) + + userRef.updateData(data) { error in + if let error = error { + completion(.failure(error)) + } else { + print("✅ User document updated: \(uid)") + completion(.success(())) + } + } + } + + func deleteUserData(uid: String, completion: @escaping (Result) -> Void) { + let userRef = db.collection("users").document(uid) + + // Delete user document + userRef.delete { error in + if let error = error { + completion(.failure(error)) + } else { + print("✅ User data deleted: \(uid)") + completion(.success(())) + } + } + + // Note: Subcollections (rooms, furniture) must be deleted separately + // For production, use Cloud Functions for recursive delete + } + + // MARK: - Rooms Subcollection + + func saveRoom(uid: String, room: RoomModel, metadata: RoomMetadata, completion: @escaping (Result) -> Void) { + let roomRef = db.collection("users").document(uid).collection("rooms").document(room.id.uuidString) + + let data: [String: Any] = [ + "id": room.id.uuidString, + "name": room.name, + "category": room.category.rawValue, + "createdAt": Timestamp(date: room.createdAt), + "updatedAt": FieldValue.serverTimestamp(), + "usdzFilename": room.usdzFilename, + "thumbnailPath": room.thumbnailPath ?? "", + "dimensions": metadata.dimensions ?? [:], + "notes": metadata.notes ?? "" + ] + + roomRef.setData(data, merge: true) { error in + if let error = error { + completion(.failure(error)) + } else { + print("✅ Room saved to Firestore: \(room.name)") + completion(.success(())) + } + } + } + + func fetchRooms(uid: String, completion: @escaping (Result<[RoomModel], Error>) -> Void) { + let roomsRef = db.collection("users").document(uid).collection("rooms") + + roomsRef.order(by: "createdAt", descending: true).getDocuments { snapshot, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let documents = snapshot?.documents else { + completion(.success([])) + return + } + + let rooms: [RoomModel] = documents.compactMap { doc in + let data = doc.data() + guard let idString = data["id"] as? String, + let id = UUID(uuidString: idString), + let name = data["name"] as? String, + let categoryString = data["category"] as? String, + let category = RoomCategory(rawValue: categoryString), + let usdzFilename = data["usdzFilename"] as? String else { + return nil + } + + let createdAt: Date + if let timestamp = data["createdAt"] as? Timestamp { + createdAt = timestamp.dateValue() + } else { + createdAt = Date() + } + + let thumbnailPath = data["thumbnailPath"] as? String + + return RoomModel( + id: id, + name: name, + category: category, + createdAt: createdAt, + usdzFilename: usdzFilename, + thumbnailPath: thumbnailPath + ) + } + + completion(.success(rooms)) + } + } + + func deleteRoom(uid: String, roomID: String, completion: @escaping (Result) -> Void) { + let roomRef = db.collection("users").document(uid).collection("rooms").document(roomID) + + roomRef.delete { error in + if let error = error { + completion(.failure(error)) + } else { + print("✅ Room deleted from Firestore: \(roomID)") + completion(.success(())) + } + } + } + + // MARK: - Furniture Subcollection + + func saveFurniture(uid: String, furnitureID: String, name: String, category: String, usdzFilename: String, completion: @escaping (Result) -> Void) { + let furnitureRef = db.collection("users").document(uid).collection("furniture").document(furnitureID) + + let data: [String: Any] = [ + "id": furnitureID, + "name": name, + "category": category, + "createdAt": FieldValue.serverTimestamp(), + "updatedAt": FieldValue.serverTimestamp(), + "usdzFilename": usdzFilename, + "thumbnailURL": "" + ] + + furnitureRef.setData(data, merge: true) { error in + if let error = error { + completion(.failure(error)) + } else { + print("✅ Furniture saved to Firestore: \(name)") + completion(.success(())) + } + } + } + + func fetchFurniture(uid: String, completion: @escaping (Result<[URL], Error>) -> Void) { + let furnitureRef = db.collection("users").document(uid).collection("furniture") + + furnitureRef.order(by: "createdAt", descending: true).getDocuments { snapshot, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let documents = snapshot?.documents else { + completion(.success([])) + return + } + + // Convert to file URLs (assuming local storage for now) + let fileManager = FileManager.default + let docsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let furnitureFolder = docsURL.appendingPathComponent("furniture", isDirectory: true) + + let urls: [URL] = documents.compactMap { doc in + let data = doc.data() + guard let filename = data["usdzFilename"] as? String else { return nil } + return furnitureFolder.appendingPathComponent(filename) + } + + completion(.success(urls)) + } + } + + func deleteFurniture(uid: String, furnitureID: String, completion: @escaping (Result) -> Void) { + let furnitureRef = db.collection("users").document(uid).collection("furniture").document(furnitureID) + + furnitureRef.delete { error in + if let error = error { + completion(.failure(error)) + } else { + print("✅ Furniture deleted from Firestore: \(furnitureID)") + completion(.success(())) + } + } + } +} +``` + +--- + +## 5. Firebase Storage Structure + +### 5.1 Storage Bucket Organization + +``` +gs://envision-production.appspot.com/ +├── users/{uid}/ +│ ├── profile/ +│ │ └── profile.jpg +│ ├── rooms/ +│ │ ├── {roomId}.usdz +│ │ └── {roomId}_thumbnail.jpg +│ └── furniture/ +│ ├── {furnitureId}.usdz +│ └── {furnitureId}_thumbnail.jpg +``` + +### 5.2 Create StorageManager + +**File**: `Envision/Managers/StorageManager.swift` + +```swift +import Foundation +import FirebaseStorage + +final class StorageManager { + static let shared = StorageManager() + + private let storage = Storage.storage() + + private init() {} + + // MARK: - Profile Picture + + func uploadProfilePicture(uid: String, image: UIImage, completion: @escaping (Result) -> Void) { + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + completion(.failure(NSError(domain: "StorageManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to JPEG"]))) + return + } + + let ref = storage.reference().child("users/\(uid)/profile/profile.jpg") + let metadata = StorageMetadata() + metadata.contentType = "image/jpeg" + + ref.putData(imageData, metadata: metadata) { metadata, error in + if let error = error { + completion(.failure(error)) + return + } + + ref.downloadURL { url, error in + if let error = error { + completion(.failure(error)) + } else if let urlString = url?.absoluteString { + print("✅ Profile picture uploaded: \(urlString)") + completion(.success(urlString)) + } else { + completion(.failure(NSError(domain: "StorageManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get download URL"]))) + } + } + } + } + + func downloadProfilePicture(url: String, completion: @escaping (Result) -> Void) { + let ref = storage.reference(forURL: url) + + ref.getData(maxSize: 5 * 1024 * 1024) { data, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data, let image = UIImage(data: data) else { + completion(.failure(NSError(domain: "StorageManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to decode image"]))) + return + } + + completion(.success(image)) + } + } + + // MARK: - Room USDZ (optional - can stay local) + + func uploadRoomUSDZ(uid: String, roomID: String, localURL: URL, completion: @escaping (Result) -> Void) { + let ref = storage.reference().child("users/\(uid)/rooms/\(roomID).usdz") + + ref.putFile(from: localURL, metadata: nil) { metadata, error in + if let error = error { + completion(.failure(error)) + return + } + + ref.downloadURL { url, error in + if let error = error { + completion(.failure(error)) + } else if let urlString = url?.absoluteString { + print("✅ Room USDZ uploaded: \(urlString)") + completion(.success(urlString)) + } else { + completion(.failure(NSError(domain: "StorageManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get download URL"]))) + } + } + } + } + + // MARK: - Thumbnail Upload + + func uploadThumbnail(uid: String, type: String, itemID: String, image: UIImage, completion: @escaping (Result) -> Void) { + guard let imageData = image.jpegData(compressionQuality: 0.7) else { + completion(.failure(NSError(domain: "StorageManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to JPEG"]))) + return + } + + let ref = storage.reference().child("users/\(uid)/\(type)/\(itemID)_thumbnail.jpg") + let metadata = StorageMetadata() + metadata.contentType = "image/jpeg" + + ref.putData(imageData, metadata: metadata) { metadata, error in + if let error = error { + completion(.failure(error)) + return + } + + ref.downloadURL { url, error in + if let error = error { + completion(.failure(error)) + } else if let urlString = url?.absoluteString { + print("✅ Thumbnail uploaded: \(urlString)") + completion(.success(urlString)) + } else { + completion(.failure(NSError(domain: "StorageManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get download URL"]))) + } + } + } + } +} +``` + +--- + +## 6. Code Implementation + +### 6.1 Integration Points + +**When to sync with Firebase:** + +1. **Room saved** (in `RoomPreviewViewController`): +```swift +// After saving locally +if let uid = AuthManager.shared.currentUserID { + FirestoreManager.shared.saveRoom(uid: uid, room: room, metadata: metadata) { result in + switch result { + case .success: + print("✅ Room synced to cloud") + case .failure(let error): + print("⚠️ Cloud sync failed (offline?): \(error)") + // Still works offline, will sync later + } + } +} +``` + +2. **Furniture saved** (in `ObjectCapturePreviewController`): +```swift +// After saving USDZ locally +if let uid = AuthManager.shared.currentUserID { + FirestoreManager.shared.saveFurniture( + uid: uid, + furnitureID: furnitureID, + name: filename, + category: category.rawValue, + usdzFilename: "\(furnitureID).usdz" + ) { result in + // Handle result + } +} +``` + +3. **Profile updated** (in `EditProfileViewController`): +```swift +// After user edits profile +UserManager.shared.updateProfile(name: newName, bio: newBio) { success in + if success { + print("✅ Profile synced") + } +} +``` + +### 6.2 Offline Support + +Firestore automatically caches data. To handle offline scenarios: + +```swift +// Check if online before showing sync indicator +func isOnline() -> Bool { + // Simple check (can be improved with Reachability) + return true // Firestore handles offline automatically +} + +// Show sync status in UI +func showSyncStatus(synced: Bool) { + // Add cloud icon to nav bar + let icon = synced ? "checkmark.icloud.fill" : "icloud.slash.fill" + let color = synced ? UIColor.systemGreen : UIColor.systemGray + // Update UI +} +``` + +--- + +## 7. Security Rules + +### 7.1 Firestore Security Rules + +**In Firebase Console → Firestore → Rules**: + +```javascript +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + + // Users collection + match /users/{userId} { + // User can read/write their own document + allow read, write: if request.auth != null && request.auth.uid == userId; + + // Rooms subcollection + match /rooms/{roomId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + // Furniture subcollection + match /furniture/{furnitureId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + } + + // Deny all other access + match /{document=**} { + allow read, write: if false; + } + } +} +``` + +### 7.2 Storage Security Rules + +**In Firebase Console → Storage → Rules**: + +```javascript +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + + // Users folder + match /users/{userId}/{allPaths=**} { + // User can read/write their own files + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + // Deny all other access + match /{allPaths=**} { + allow read, write: if false; + } + } +} +``` + +### 7.3 Test Security Rules + +```swift +// Try to access another user's data (should fail) +func testSecurityRules() { + let otherUID = "some_other_user_id" + + FirestoreManager.shared.fetchUserDocument(uid: otherUID) { result in + switch result { + case .success: + print("❌ Security rules are broken!") + case .failure: + print("✅ Security rules working correctly") + } + } +} +``` + +--- + +## 8. Migration Strategy + +### 8.1 Phase 1: Add Firebase (No Breaking Changes) + +**Week 1**: +- Add Firebase SDK +- Create manager classes +- Keep local storage as primary +- Firebase as "backup sync" only + +### 8.2 Phase 2: Dual Sync (Local + Cloud) + +**Week 2**: +- Every save operation writes to both local + Firestore +- On app launch, compare timestamps and merge +- Show "Syncing..." indicator + +### 8.3 Phase 3: Cloud-First (Optional) + +**Week 3+**: +- Firestore becomes source of truth +- Local storage as cache only +- Implement "Export to Files" for backup + +### 8.4 Data Migration Script + +```swift +func migrateLocalDataToFirebase(completion: @escaping () -> Void) { + guard let uid = AuthManager.shared.currentUserID else { + completion() + return + } + + print("🔄 Starting data migration...") + + let group = DispatchGroup() + + // Migrate rooms + let roomsMetadata = MetadataManager.shared.loadMetadata() + for (filename, metadata) in roomsMetadata.rooms { + group.enter() + + // Create RoomModel from metadata + let room = RoomModel( + id: UUID(), + name: metadata.name, + category: RoomCategory(rawValue: metadata.category) ?? .other, + createdAt: ISO8601DateFormatter().date(from: metadata.createdAt) ?? Date(), + usdzFilename: filename, + thumbnailPath: nil + ) + + FirestoreManager.shared.saveRoom(uid: uid, room: room, metadata: metadata) { _ in + group.leave() + } + } + + // Wait for all migrations to complete + group.notify(queue: .main) { + print("✅ Data migration complete!") + completion() + } +} +``` + +--- + +## 9. Testing Plan + +### 9.1 Unit Tests + +```swift +import XCTest +@testable import Envision + +class FirebaseTests: XCTestCase { + + func testAuthSignup() { + let expectation = XCTestExpectation(description: "Signup completes") + + AuthManager.shared.signUp(email: "test@example.com", password: "Test1234", name: "Test User") { result in + switch result { + case .success(let user): + XCTAssertNotNil(user) + expectation.fulfill() + case .failure(let error): + XCTFail("Signup failed: \(error)") + } + } + + wait(for: [expectation], timeout: 10.0) + } + + func testFirestoreSaveRoom() { + // Test saving a room + } + + func testStorageUpload() { + // Test uploading an image + } +} +``` + +### 9.2 Integration Tests + +**Test Scenarios**: +1. Sign up → Create room → Logout → Login → Verify room exists +2. Offline mode → Create room → Go online → Verify sync +3. Profile picture upload → Verify URL in Firestore +4. Password reset → Verify email sent + +### 9.3 Manual Testing Checklist + +- [ ] Fresh install → Signup → Create room → Logout → Login → Room still there +- [ ] Airplane mode → Create room → Toggle off → Room syncs +- [ ] Delete account → Verify Firestore data deleted +- [ ] Password reset → Receive email → Reset works +- [ ] Profile picture upload → Shows in app +- [ ] Multiple devices → Changes sync + +--- + +## 10. Deployment Checklist + +### 10.1 Pre-Launch + +- [ ] Firebase project created (production) +- [ ] `GoogleService-Info.plist` added to Xcode +- [ ] Security rules deployed and tested +- [ ] Error handling added to all Firebase calls +- [ ] Loading indicators for async operations +- [ ] Offline caching enabled +- [ ] Analytics events configured (optional) +- [ ] Crashlytics configured (optional) + +### 10.2 App Store Submission + +- [ ] Update privacy policy (mention cloud storage) +- [ ] Add "Sign in with Apple" (if using social auth) +- [ ] Test on multiple iOS versions (17.0+) +- [ ] Test on different devices (iPhone SE, Pro Max, iPad) +- [ ] Beta test via TestFlight (50+ users) + +### 10.3 Post-Launch Monitoring + +- [ ] Monitor Firebase Console → Usage +- [ ] Check Firestore read/write counts (cost optimization) +- [ ] Monitor Storage usage (cost optimization) +- [ ] Set up billing alerts +- [ ] Review Crashlytics reports weekly + +--- + +## Appendix: Cost Estimation + +### Firebase Free Tier (Spark Plan) + +**Firestore**: +- 50k reads/day +- 20k writes/day +- 20k deletes/day +- 1 GB storage + +**Storage**: +- 5 GB storage +- 1 GB/day downloads + +**Authentication**: +- Unlimited (free) + +### When to Upgrade (Blaze Plan) + +- 1000+ daily active users +- Heavy USDZ file uploads (>5 GB/month) +- Need phone authentication + +**Estimated Cost** (10k users, moderate usage): +- $25-50/month + +--- + +**Document Version**: 1.0 +**Last Updated**: January 21, 2026 +**Estimated Implementation Time**: 10-14 days +**Priority**: High + +--- + +*End of Firebase Backend Implementation Plan* diff --git a/FURNITURE_SCANNING_GUIDE.md b/FURNITURE_SCANNING_GUIDE.md new file mode 100644 index 0000000..074f0ca --- /dev/null +++ b/FURNITURE_SCANNING_GUIDE.md @@ -0,0 +1,879 @@ +# Furniture Scanning Guide - Technical Documentation & Improvements + +> **A comprehensive guide to the Object Capture implementation in EnVision, with detailed recommendations for generating higher quality 3D models.** + +--- + +## Table of Contents + +1. [Current Implementation Overview](#1-current-implementation-overview) +2. [How Object Capture Works](#2-how-object-capture-works) +3. [Current Code Analysis](#3-current-code-analysis) +4. [Issues with Current Implementation](#4-issues-with-current-implementation) +5. [Recommendations for Better 3D Models](#5-recommendations-for-better-3d-models) +6. [Code Improvements](#6-code-improvements) +7. [Best Practices for Users](#7-best-practices-for-users) +8. [Advanced Features to Add](#8-advanced-features-to-add) +9. [Troubleshooting Common Issues](#9-troubleshooting-common-issues) + +--- + +## 1. Current Implementation Overview + +### File Structure +``` +Object Capture/ +├── ObjectScanViewController.swift # Camera capture UI (411 lines) +├── ObjectCapturePreviewController.swift # Preview & processing (553 lines) +├── ARMeshExporter.swift # Empty/unused +├── ArrowGuideView.swift # Visual guidance +├── FeedbackBubble.swift # User feedback UI +├── InstructionOverlay.swift # Instructions +└── ProgressRingView.swift # Progress indicator +``` + +### Current Flow +``` +┌─────────────────────────┐ +│ ScanFurnitureVC │ +│ │ +│ [Scan ▾] → Automatic │ +└───────────┬─────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ ObjectScanViewController │ +│ │ +│ 1. Show instruction card │ +│ 2. Start camera session │ +│ 3. Auto-capture every 1.0s │ +│ 4. Save JPGs to temp folder │ +│ 5. Track photo count │ +│ │ +│ [Finish Capture] │ +└───────────┬─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ ObjectCapturePreviewController │ +│ │ +│ 1. Display captured photos │ +│ 2. Show quality assessment │ +│ 3. Start PhotogrammetrySession │ +│ 4. Process images → USDZ │ +│ 5. Save via SaveManager │ +│ │ +│ [Generate 3D Model] │ +└─────────────────────────────────────┘ +``` + +--- + +## 2. How Object Capture Works + +### Apple's PhotogrammetrySession + +PhotogrammetrySession uses **Structure from Motion (SfM)** and **Multi-View Stereo (MVS)** algorithms: + +1. **Feature Detection**: Identifies distinctive points in each image +2. **Feature Matching**: Finds corresponding points across images +3. **Camera Pose Estimation**: Determines camera position for each photo +4. **Sparse Point Cloud**: Creates initial 3D points +5. **Dense Reconstruction**: Fills in the mesh +6. **Texture Mapping**: Applies colors from photos + +### Key Factors for Quality + +| Factor | Impact | Current Implementation | +|--------|--------|------------------------| +| **Photo Count** | High | 20-80+ photos ✓ | +| **Photo Overlap** | Critical | Not controlled ⚠️ | +| **Image Quality** | High | Standard JPG ⚠️ | +| **Lighting** | Critical | Basic flash only ⚠️ | +| **Camera Angles** | Critical | Not guided ⚠️ | +| **Object Coverage** | High | No visual feedback ⚠️ | +| **Motion Blur** | High | No detection ❌ | +| **Focus** | High | Auto-focus only ⚠️ | + +--- + +## 3. Current Code Analysis + +### ObjectScanViewController.swift + +#### Camera Setup +```swift +private func setupCamera() { + session.beginConfiguration() + session.sessionPreset = .photo // ⚠️ Could use .high for better quality + + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device) + // ... +} +``` + +**Issues:** +- Uses default video device (wide-angle camera) +- No manual focus control +- No exposure optimization +- No image stabilization settings + +#### Auto Capture Timer +```swift +private func startAutoCapture() { + captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + self.takePhoto() + } +} +``` + +**Issues:** +- Fixed 1-second interval regardless of movement +- No motion detection +- No blur detection before capture +- No overlap calculation + +#### Photo Capture +```swift +private func takePhoto() { + let settings = AVCapturePhotoSettings() + photoOutput.capturePhoto(with: settings, delegate: self) +} +``` + +**Issues:** +- Default photo settings (no HEIF, no RAW) +- No flash optimization +- No HDR +- No depth data capture + +### ObjectCapturePreviewController.swift + +#### Photogrammetry Configuration +```swift +var config = PhotogrammetrySession.Configuration() +config.sampleOrdering = .sequential +config.featureSensitivity = .normal // ⚠️ Could be .high +``` + +**Issues:** +- `featureSensitivity = .normal` misses fine details +- No object masking enabled +- No custom bounding box + +#### Model Quality +```swift +let request = PhotogrammetrySession.Request.modelFile(url: outputURL) +// ⚠️ No detail level specified - defaults to .preview +``` + +**Issues:** +- Missing `detail` parameter (defaults to `.preview`) +- Should use `.medium` or `.full` for better quality + +--- + +## 4. Issues with Current Implementation + +### Critical Issues + +| Issue | Impact | Solution | +|-------|--------|----------| +| **No motion blur detection** | Blurry photos ruin reconstruction | Add accelerometer-based capture | +| **Fixed capture interval** | May miss angles or capture duplicates | Use motion-based triggering | +| **Default detail level** | Low-quality output mesh | Specify `.medium` or `.full` | +| **No depth data** | Less accurate geometry | Enable LiDAR if available | +| **No coverage guidance** | Users don't know what angles to capture | Add visual coverage map | + +### Medium Issues + +| Issue | Impact | Solution | +|-------|--------|----------| +| **No HEIF format** | Larger files, less color depth | Enable HEIF capture | +| **No HDR** | Poor handling of shadows/highlights | Enable Smart HDR | +| **No focus lock** | Inconsistent focus across shots | Lock focus on object | +| **No exposure lock** | Brightness varies between photos | Lock exposure | +| **No object masking** | Background included in model | Enable masking | + +### Minor Issues + +| Issue | Impact | Solution | +|-------|--------|----------| +| **No capture sound** | Users unsure if photo taken | Add shutter sound option | +| **No preview of last photo** | Can't verify quality | Show thumbnail | +| **No delete bad photo** | Can't remove blurry shots | Add review mode | + +--- + +## 5. Recommendations for Better 3D Models + +### 5.1 Camera Configuration Improvements + +```swift +private func setupOptimizedCamera() { + session.beginConfiguration() + session.sessionPreset = .photo + + // Prefer LiDAR-enabled camera if available + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInLiDARDepthCamera, .builtInWideAngleCamera], + mediaType: .video, + position: .back + ) + + guard let device = discoverySession.devices.first, + let input = try? AVCaptureDeviceInput(device: device) else { return } + + // Configure device for best quality + try? device.lockForConfiguration() + + // Enable auto-focus with continuous adjustment + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + + // Enable auto-exposure with bias + if device.isExposureModeSupported(.continuousAutoExposure) { + device.exposureMode = .continuousAutoExposure + } + + // Enable optical image stabilization + if device.isOpticalStabilizationSupported { + // Applied in photo settings + } + + device.unlockForConfiguration() + + // Add depth output if available + if let depthOutput = AVCaptureDepthDataOutput() { + if session.canAddOutput(depthOutput) { + session.addOutput(depthOutput) + } + } + + session.commitConfiguration() +} +``` + +### 5.2 Smart Capture with Motion Detection + +```swift +import CoreMotion + +class SmartCaptureManager { + private let motionManager = CMMotionManager() + private var lastCaptureAttitude: CMAttitude? + private let minimumRotationDegrees: Double = 5.0 + + func startMotionTracking(onSignificantMovement: @escaping () -> Void) { + guard motionManager.isDeviceMotionAvailable else { return } + + motionManager.deviceMotionUpdateInterval = 0.1 + motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in + guard let motion = motion, let self = self else { return } + + if let lastAttitude = self.lastCaptureAttitude { + let rotation = self.rotationDifference(from: lastAttitude, to: motion.attitude) + + if rotation >= self.minimumRotationDegrees { + onSignificantMovement() + self.lastCaptureAttitude = motion.attitude.copy() as? CMAttitude + } + } else { + self.lastCaptureAttitude = motion.attitude.copy() as? CMAttitude + } + } + } + + private func rotationDifference(from: CMAttitude, to: CMAttitude) -> Double { + let deltaRoll = abs(to.roll - from.roll) * 180 / .pi + let deltaPitch = abs(to.pitch - from.pitch) * 180 / .pi + let deltaYaw = abs(to.yaw - from.yaw) * 180 / .pi + return max(deltaRoll, deltaPitch, deltaYaw) + } + + func isDeviceStable(threshold: Double = 0.5) -> Bool { + guard let motion = motionManager.deviceMotion else { return false } + + let acceleration = motion.userAcceleration + let magnitude = sqrt(pow(acceleration.x, 2) + pow(acceleration.y, 2) + pow(acceleration.z, 2)) + + return magnitude < threshold + } +} +``` + +### 5.3 Enhanced Photo Settings + +```swift +private func takeOptimizedPhoto() { + var settings = AVCapturePhotoSettings() + + // Use HEIF for better quality and smaller size + if photoOutput.availablePhotoCodecTypes.contains(.hevc) { + settings = AVCapturePhotoSettings(format: [ + AVVideoCodecKey: AVVideoCodecType.hevc + ]) + } + + // Enable high resolution + settings.isHighResolutionPhotoEnabled = true + + // Enable Smart HDR if available + if photoOutput.isHighResolutionCaptureEnabled { + settings.isHighResolutionPhotoEnabled = true + } + + // Enable flash in low light + if let device = AVCaptureDevice.default(for: .video) { + if device.hasTorch && isLowLight() { + settings.flashMode = .auto + } + } + + // Enable depth data if available + if photoOutput.isDepthDataDeliverySupported { + settings.isDepthDataDeliveryEnabled = true + } + + // Enable image stabilization + settings.photoQualityPrioritization = .quality + + photoOutput.capturePhoto(with: settings, delegate: self) +} + +private func isLowLight() -> Bool { + guard let device = AVCaptureDevice.default(for: .video) else { return false } + return device.iso > 400 // Arbitrary threshold +} +``` + +### 5.4 Blur Detection Before Capture + +```swift +import Vision + +class BlurDetector { + func detectBlur(in image: UIImage, completion: @escaping (Bool, Double) -> Void) { + guard let cgImage = image.cgImage else { + completion(true, 0) + return + } + + let request = VNGenerateImageFeaturePrintRequest { request, error in + // Use Laplacian variance for blur detection + let laplacianVariance = self.calculateLaplacianVariance(cgImage) + let isBlurry = laplacianVariance < 100 // Threshold + completion(isBlurry, laplacianVariance) + } + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + try? handler.perform([request]) + } + + private func calculateLaplacianVariance(_ image: CGImage) -> Double { + // Simplified blur detection using pixel variance + // In production, use Metal for GPU-accelerated Laplacian + + let context = CIContext() + let ciImage = CIImage(cgImage: image) + + // Apply edge detection filter + guard let filter = CIFilter(name: "CIEdges") else { return 0 } + filter.setValue(ciImage, forKey: kCIInputImageKey) + filter.setValue(1.0, forKey: kCIInputIntensityKey) + + guard let output = filter.outputImage, + let cgOutput = context.createCGImage(output, from: output.extent) else { + return 0 + } + + // Calculate variance of edge image + return calculateVariance(cgOutput) + } + + private func calculateVariance(_ image: CGImage) -> Double { + // Simplified variance calculation + return 150.0 // Placeholder + } +} +``` + +### 5.5 Visual Coverage Guidance + +```swift +class CoverageTracker { + private var capturedAngles: [(pitch: Float, yaw: Float)] = [] + + struct CoverageSegment { + let pitchRange: ClosedRange + let yawRange: ClosedRange + var isCovered: Bool = false + } + + private var segments: [CoverageSegment] = [] + + init() { + // Create 3D grid of segments (spherical coverage) + // Pitch: -60° to +60° (above and below) + // Yaw: 0° to 360° (around object) + + for pitch in stride(from: -60, through: 60, by: 30) { + for yaw in stride(from: 0, through: 330, by: 30) { + segments.append(CoverageSegment( + pitchRange: Float(pitch)...Float(pitch + 30), + yawRange: Float(yaw)...Float(yaw + 30) + )) + } + } + } + + func recordCapture(pitch: Float, yaw: Float) { + capturedAngles.append((pitch, yaw)) + + for i in segments.indices { + if segments[i].pitchRange.contains(pitch) && + segments[i].yawRange.contains(yaw) { + segments[i].isCovered = true + } + } + } + + var coveragePercentage: Float { + let covered = segments.filter { $0.isCovered }.count + return Float(covered) / Float(segments.count) * 100 + } + + var uncoveredDirections: [String] { + var directions: [String] = [] + + let topCovered = segments.filter { $0.pitchRange.lowerBound >= 30 && $0.isCovered }.count + let bottomCovered = segments.filter { $0.pitchRange.upperBound <= -30 && $0.isCovered }.count + + if topCovered < 4 { directions.append("Capture from above") } + if bottomCovered < 4 { directions.append("Capture from below") } + + return directions + } +} +``` + +### 5.6 Improved Photogrammetry Configuration + +```swift +func startOptimizedPhotogrammetry(inputFolder: URL, outputURL: URL) { + var config = PhotogrammetrySession.Configuration() + + // Use highest quality settings + config.sampleOrdering = .sequential + config.featureSensitivity = .high // Capture fine details + config.isObjectMaskingEnabled = true // Remove background + + guard let session = try? PhotogrammetrySession( + input: inputFolder, + configuration: config + ) else { + handleError(message: "Failed to create session") + return + } + + // Request high-quality model + // Options: .preview (fastest), .reduced, .medium, .full, .raw + let request = PhotogrammetrySession.Request.modelFile( + url: outputURL, + detail: .medium // Balance of quality and speed + // Use .full for highest quality (slower) + ) + + // Process with progress tracking + Task { + do { + for try await output in session.outputs { + await handleOutput(output) + } + } catch { + await handleError(error) + } + } + + try? session.process(requests: [request]) +} +``` + +--- + +## 6. Code Improvements + +### 6.1 Updated ObjectScanViewController + +Key changes to implement: + +```swift +// Add these properties +private let motionManager = CMMotionManager() +private var lastCaptureAttitude: CMAttitude? +private var blurDetector = BlurDetector() +private var coverageTracker = CoverageTracker() +private var captureMode: CaptureMode = .motionBased + +enum CaptureMode { + case timerBased // Current: every 1 second + case motionBased // New: capture on movement + case manual // New: tap to capture +} + +// Replace startAutoCapture with smart capture +private func startSmartCapture() { + switch captureMode { + case .timerBased: + startTimerCapture(interval: 0.5) // Faster for more overlap + + case .motionBased: + startMotionBasedCapture() + + case .manual: + // Show manual capture button + showManualCaptureButton() + } +} + +private func startMotionBasedCapture() { + guard motionManager.isDeviceMotionAvailable else { + // Fallback to timer + startTimerCapture(interval: 1.0) + return + } + + motionManager.deviceMotionUpdateInterval = 0.05 + motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, _ in + guard let self = self, let motion = motion else { return } + + // Check if device has moved significantly + if self.shouldCaptureBasedOnMotion(motion) { + // Check if device is stable (not moving during shot) + if self.isDeviceStable(motion) { + self.takeOptimizedPhoto() + self.lastCaptureAttitude = motion.attitude.copy() as? CMAttitude + } + } + } +} + +private func shouldCaptureBasedOnMotion(_ motion: CMDeviceMotion) -> Bool { + guard let lastAttitude = lastCaptureAttitude else { + return true // First capture + } + + // Calculate rotation difference + let current = motion.attitude + let deltaRoll = abs(current.roll - lastAttitude.roll) * 180 / .pi + let deltaPitch = abs(current.pitch - lastAttitude.pitch) * 180 / .pi + let deltaYaw = abs(current.yaw - lastAttitude.yaw) * 180 / .pi + + let maxDelta = max(deltaRoll, deltaPitch, deltaYaw) + + return maxDelta >= 5.0 // Capture every 5 degrees of rotation +} + +private func isDeviceStable(_ motion: CMDeviceMotion) -> Bool { + let acc = motion.userAcceleration + let magnitude = sqrt(acc.x*acc.x + acc.y*acc.y + acc.z*acc.z) + return magnitude < 0.3 // Device is relatively still +} +``` + +### 6.2 Updated ObjectCapturePreviewController + +Key changes: + +```swift +@objc private func startProcessing() { + // ... existing setup code ... + + // IMPROVED: Use better configuration + var config = PhotogrammetrySession.Configuration() + config.sampleOrdering = .sequential + config.featureSensitivity = .high // Changed from .normal + config.isObjectMaskingEnabled = true // Added: remove background + + guard let session = try? PhotogrammetrySession( + input: self.imagesFolder, + configuration: config + ) else { + handleError(message: "Failed to create session") + return + } + + // IMPROVED: Specify detail level + let request = PhotogrammetrySession.Request.modelFile( + url: outputURL, + detail: .medium // Added: was using default .preview + ) + + // ... rest of processing ... +} +``` + +--- + +## 7. Best Practices for Users + +### 7.1 Lighting + +| Condition | Quality Impact | Recommendation | +|-----------|----------------|----------------| +| **Bright, even lighting** | ⭐⭐⭐⭐⭐ | Best results | +| **Natural daylight** | ⭐⭐⭐⭐ | Very good | +| **Single light source** | ⭐⭐⭐ | Shadows may cause issues | +| **Low light** | ⭐⭐ | Grainy photos, poor detail | +| **Direct sunlight** | ⭐⭐ | Harsh shadows, overexposure | + +**Recommendations:** +- Use diffused lighting from multiple angles +- Avoid harsh shadows +- Ensure the object is evenly lit +- Use the flashlight for fill light, not as primary + +### 7.2 Object Placement + +``` +GOOD: BAD: + +┌─────────────────┐ ┌─────────────────┐ +│ │ │ ████████████ │ +│ ┌─────┐ │ │ █ Object █ │ +│ │ │ │ │ ████████████ │ +│ │ Obj │ │ │ │ +│ └─────┘ │ │ │ +│ │ └─────────────────┘ +│ Clear space │ Against wall +└─────────────────┘ (can't capture back) +``` + +**Do:** +- Place object in center of open space +- Ensure 360° access around object +- Keep floor/background simple and matte +- Use contrasting background color + +**Don't:** +- Place against walls +- Put on reflective surfaces +- Include other objects in frame +- Use shiny/glass backgrounds + +### 7.3 Capture Technique + +``` +TOP VIEW - Capture Pattern: + + ★ (start) + ↓ + ← ● ● ● → + ● ● + ● OBJ ● + ● ● + ← ● ● ● → + ↑ + ★ (end at different height) + +Walk in circles at 3 different heights: +1. Eye level +2. Above (looking down 30-45°) +3. Below (looking up 30-45°) +``` + +**Photo Overlap:** +``` +Photo 1 Photo 2 Photo 3 +┌──────────────────────────────┐ +│ ████████ │ +│ ████████████████ │ +│ ████████████████ │ +│ ████████████████ │ +│ ████████████ │ +└──────────────────────────────┘ + 60-80% overlap recommended +``` + +### 7.4 Minimum Photo Requirements + +| Object Size | Minimum Photos | Recommended | Maximum Useful | +|-------------|---------------|-------------|----------------| +| Small (<30cm) | 30 | 50-70 | 100 | +| Medium (30-100cm) | 40 | 70-100 | 150 | +| Large (>100cm) | 50 | 100-150 | 200 | + +**Quality Formula:** +``` +Model Quality ≈ (Photo Count × Photo Quality × Coverage) / Processing Detail +``` + +--- + +## 8. Advanced Features to Add + +### 8.1 Real-time Quality Feedback + +```swift +// Add to ObjectScanViewController +struct CaptureQuality { + var sharpness: Float = 0 + var exposure: Float = 0 + var coverage: Float = 0 + + var overall: Float { + (sharpness + exposure + coverage) / 3.0 + } + + var recommendation: String { + if sharpness < 0.5 { return "Hold steadier" } + if exposure < 0.5 { return "Improve lighting" } + if coverage < 0.5 { return "Capture more angles" } + return "Looking good!" + } +} +``` + +### 8.2 AR Preview Before Processing + +```swift +// Add option to preview captured points in AR +func showPointCloudPreview() { + // Use captured images to show approximate 3D structure + // Helps users identify missing coverage areas +} +``` + +### 8.3 Object Bounding Box + +```swift +// Let user define object bounds for better masking +struct ObjectBounds { + var center: SIMD3 + var size: SIMD3 +} + +// Use in configuration +config.isObjectMaskingEnabled = true +// Define custom bounds if object detection fails +``` + +### 8.4 Quality Presets + +```swift +enum CaptureQualityPreset { + case quick // 20-30 photos, .reduced detail + case standard // 40-60 photos, .medium detail + case highQuality // 80-120 photos, .full detail + case maximum // 150+ photos, .raw detail + + var photoCount: ClosedRange { + switch self { + case .quick: return 20...30 + case .standard: return 40...60 + case .highQuality: return 80...120 + case .maximum: return 150...250 + } + } + + var detailLevel: PhotogrammetrySession.Request.Detail { + switch self { + case .quick: return .reduced + case .standard: return .medium + case .highQuality: return .full + case .maximum: return .raw + } + } +} +``` + +### 8.5 Resume Capture Session + +```swift +// Save capture progress for later +func saveProgress() { + let progress = CaptureProgress( + folderURL: tempFolderURL, + photoCount: images.count, + capturedAngles: coverageTracker.capturedAngles, + timestamp: Date() + ) + + try? JSONEncoder().encode(progress) + .write(to: progressFileURL) +} + +func resumeCapture() { + guard let data = try? Data(contentsOf: progressFileURL), + let progress = try? JSONDecoder().decode(CaptureProgress.self, from: data) else { + return + } + + tempFolderURL = progress.folderURL + images = loadExistingImages() + coverageTracker.restore(progress.capturedAngles) +} +``` + +--- + +## 9. Troubleshooting Common Issues + +### 9.1 Poor Model Quality + +| Symptom | Cause | Solution | +|---------|-------|----------| +| Holes in mesh | Missing photo angles | Ensure 360° coverage at multiple heights | +| Blurry textures | Motion blur in photos | Hold device steadier, use stabilization | +| Wrong scale | No reference object | Include object of known size | +| Missing details | Low feature sensitivity | Use `.high` feature sensitivity | +| Background in model | Object masking failed | Use contrasting background | + +### 9.2 Processing Failures + +| Error | Cause | Solution | +|-------|-------|----------| +| "Not enough images" | < 20 photos | Capture at least 30 photos | +| "Failed to find features" | Low-texture object | Add temporary markers | +| "Out of memory" | Too many high-res photos | Reduce photo count or resolution | +| "Processing timeout" | Complex geometry | Use `.reduced` detail first | + +### 9.3 Specific Object Types + +| Object Type | Challenge | Solution | +|-------------|-----------|----------| +| **Shiny/reflective** | Inconsistent reflections | Use polarizer, matte spray | +| **Transparent** | Can't detect surfaces | Not suitable for photogrammetry | +| **Very dark** | Features not visible | Increase lighting significantly | +| **Very thin** | Not enough depth | Capture at extreme angles | +| **Symmetric** | Matching confusion | Add temporary asymmetric markers | +| **Repetitive patterns** | Feature matching errors | Photograph in sections | + +--- + +## Summary: Priority Improvements + +### Immediate (High Impact, Low Effort) +1. ✅ Change `featureSensitivity` to `.high` +2. ✅ Add `detail: .medium` to model request +3. ✅ Enable `isObjectMaskingEnabled = true` +4. ✅ Reduce capture interval to 0.5 seconds + +### Short Term (High Impact, Medium Effort) +1. Add motion-based capture triggering +2. Add blur detection before saving photo +3. Show coverage percentage to user +4. Add quality presets (Quick/Standard/High) + +### Long Term (Medium Impact, High Effort) +1. Implement full coverage guidance system +2. Add AR point cloud preview +3. Support depth data from LiDAR +4. Add resume/pause capture functionality + +--- + +*Last Updated: January 3, 2026* +*EnVision Furniture Scanning Documentation* diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..117e734 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,590 @@ +# EnVision - Improvements & Recommendations + +> **A comprehensive analysis of the EnVision iOS project with actionable improvements, feature suggestions, and technical debt recommendations.** + +--- + +## 📊 Executive Summary + +After a thorough review of the entire codebase, this document outlines: +- **Critical Issues** that need immediate attention +- **Code Quality Improvements** for maintainability +- **Feature Enhancements** for better UX +- **Performance Optimizations** for smoother experience +- **Architecture Improvements** for scalability +- **Security Recommendations** for data protection +- **Accessibility Improvements** for inclusivity +- **Testing Recommendations** for reliability + +--- + +## 🚨 Critical Issues (Priority: HIGH) + +### 1. No Real Backend Integration +**Current State:** Authentication is simulated locally using `UserDefaults`. + +**Issue:** +- User data is only stored locally +- No real authentication/authorization +- No data sync across devices + +**Recommendation:** +``` +- Integrate Firebase Authentication (Email, Google, Apple Sign-In) +- Use Firebase Firestore for user data +- Use Firebase Storage for 3D models and profile images +- Implement proper token-based authentication +``` + +### 2. Missing Error Handling +**Current State:** Many async operations lack proper error handling. + +**Files Affected:** +- `SaveManager.swift` - Some completion handlers ignore errors +- `ObjectScanViewController.swift` - Photo capture errors not handled +- `RoomPlanScannerViewController.swift` - Session errors not displayed + +**Recommendation:** +```swift +// Add comprehensive error handling with user-friendly messages +enum AppError: LocalizedError { + case scanFailed(String) + case saveFailed(String) + case networkError(String) + case authenticationFailed(String) + + var errorDescription: String? { + switch self { + case .scanFailed(let msg): return "Scan failed: \(msg)" + case .saveFailed(let msg): return "Save failed: \(msg)" + case .networkError(let msg): return "Network error: \(msg)" + case .authenticationFailed(let msg): return "Auth failed: \(msg)" + } + } +} +``` + +### 3. Memory Management Issues +**Current State:** Thumbnail caches have no size limits. + +**Files Affected:** +- `MyRoomsViewController.swift` - `thumbnailCache` +- `ScanFurnitureViewController.swift` - `thumbnailCache` + +**Recommendation:** +```swift +// Add cache limits +thumbnailCache.countLimit = 50 +thumbnailCache.totalCostLimit = 50 * 1024 * 1024 // 50MB +``` + +--- + +## 🔧 Code Quality Improvements (Priority: MEDIUM) + +### 1. Create a Unified Networking Layer +**Current State:** No networking layer exists. + +**Recommendation:** Create `NetworkManager.swift` +``` +Services/ +├── NetworkManager.swift # API calls +├── FirebaseService.swift # Firebase integration +├── AuthService.swift # Authentication +└── StorageService.swift # File storage +``` + +### 2. Implement MVVM Architecture +**Current State:** Massive View Controllers with business logic mixed in. + +**Files Affected:** +- `MyRoomsViewController.swift` (652 lines) +- `ScanFurnitureViewController.swift` (1092 lines) +- `ProfileViewController.swift` (291 lines) + +**Recommendation:** +``` +Screens/MainTabs/Rooms/ +├── MyRoomsViewController.swift # View only +├── MyRoomsViewModel.swift # Business logic +├── MyRoomsCoordinator.swift # Navigation +└── Models/ + ├── RoomModel.swift + └── RoomMetadata.swift +``` + +### 3. Remove Code Duplication +**Duplicated Code Found:** + +| Code Pattern | Files | Recommendation | +|--------------|-------|----------------| +| `fileSizeString(for:)` | MyRoomsVC, ScanFurnitureVC | Move to `FileHelper.swift` | +| `fileDateString(for:)` | MyRoomsVC, ScanFurnitureVC | Move to `FileHelper.swift` | +| `showToast(message:)` | Multiple VCs | Move to `UIViewController+Toast.swift` | +| `showLoading/hideLoading` | Multiple VCs | Create `LoadingOverlay` component | +| ChipCell implementations | RoomCell, FurnitureChipCell | Create unified `FilterChipCell` | + +### 4. Use Protocols for Abstraction +**Recommendation:** +```swift +// Create protocols for common patterns +protocol ModelManaging { + func loadModels() + func deleteModel(at url: URL) + func renameModel(at url: URL, to name: String) +} + +protocol ThumbnailGenerating { + func generateThumbnail(for url: URL, completion: @escaping (UIImage?) -> Void) +} + +protocol Searchable { + var searchController: UISearchController { get } + func filterContent(for searchText: String) +} +``` + +### 5. Add Documentation +**Files Missing Documentation:** +- Most view controllers lack class-level documentation +- Public methods lack parameter documentation +- Complex algorithms lack inline comments + +**Recommendation:** +```swift +/// Manages the display and interaction of room models. +/// +/// This view controller provides: +/// - Grid display of scanned room models +/// - Search and filter functionality +/// - Multi-select for batch operations +/// - Context menus for individual actions +/// +/// - Note: Requires iOS 16.0+ for RoomPlan support +final class MyRoomsViewController: UIViewController { + // ... +} +``` + +--- + +## ✨ Feature Enhancements (Priority: MEDIUM) + +### 1. Add Onboarding Improvements +| Feature | Status | Recommendation | +|---------|--------|----------------| +| Skip confirmation | ❌ Missing | Add "Are you sure?" dialog | +| Progress indicator | ❌ Missing | Show "Step 1 of 3" | +| Demo content | ❌ Missing | Add sample room/furniture for new users | +| Tutorial overlay | ❌ Missing | First-time hints for main features | + +### 2. Add Room Features +| Feature | Status | Recommendation | +|---------|--------|----------------| +| Room dimensions display | ❌ Missing | Show width × height × length | +| Floor plan view | ❌ Missing | Add 2D top-down view | +| Room comparison | ❌ Missing | Side-by-side view of 2 rooms | +| Measurement tool | ❌ Missing | Tap-to-measure in AR | +| Export formats | Partial | Add OBJ, FBX, GLB export | +| Room templates | ❌ Missing | Pre-defined room layouts | +| Favorites | ❌ Missing | Star rooms for quick access | +| Recently viewed | ❌ Missing | Track view history | + +### 3. Add Furniture Features +| Feature | Status | Recommendation | +|---------|--------|----------------| +| Furniture dimensions | ❌ Missing | Show size after scan | +| Material detection | ❌ Missing | Identify wood, fabric, metal | +| Color extraction | ❌ Missing | Dominant color from model | +| Price estimation | ❌ Missing | AI-based price range | +| Similar items | ❌ Missing | Find similar furniture online | +| Scan quality score | ❌ Missing | Rate the 3D model quality | +| Before/After | ❌ Missing | Compare room with/without furniture | + +### 4. Add Profile Features +| Feature | Status | Recommendation | +|---------|--------|----------------| +| Account deletion | ❌ Missing | GDPR compliance | +| Data export | ❌ Missing | Export all user data | +| Backup to iCloud | ❌ Missing | Sync across devices | +| Activity log | ❌ Missing | Track scans, imports, etc. | +| Storage usage | ❌ Missing | Show disk space used | +| Subscription tiers | ❌ Missing | Free vs Pro features | + +### 5. Add Social Features +| Feature | Status | Recommendation | +|---------|--------|----------------| +| Share to social | Partial | Add Instagram, TikTok | +| Collaborative viewing | ❌ Missing | SharePlay support | +| Public gallery | ❌ Missing | Browse community rooms | +| Comments/ratings | ❌ Missing | For shared models | + +--- + +## ⚡ Performance Optimizations (Priority: MEDIUM) + +### 1. Lazy Loading for Collections +**Current State:** All thumbnails generated on load. + +**Recommendation:** +```swift +// Implement prefetching +extension MyRoomsViewController: UICollectionViewDataSourcePrefetching { + func collectionView(_ collectionView: UICollectionView, + prefetchItemsAt indexPaths: [IndexPath]) { + for indexPath in indexPaths { + let url = displayFiles[indexPath.item] + generateThumbnail(for: url) { _ in } + } + } + + func collectionView(_ collectionView: UICollectionView, + cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + // Cancel pending thumbnail requests + } +} +``` + +### 2. Background Processing +**Current State:** Some file operations block main thread. + +**Recommendation:** +```swift +// Use background queues consistently +private let processingQueue = DispatchQueue(label: "com.envision.processing", + qos: .userInitiated, + attributes: .concurrent) + +func processModel(at url: URL) { + processingQueue.async { + // Heavy processing + DispatchQueue.main.async { + // UI updates + } + } +} +``` + +### 3. Image Optimization +**Recommendation:** +- Compress thumbnails before caching +- Use `UIImage.preparingThumbnail(of:)` for efficient resizing +- Implement progressive image loading + +### 4. Model Loading Optimization +**Recommendation:** +```swift +// Preload models in background +func preloadModel(at url: URL) async throws -> Entity { + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + let entity = try Entity.load(contentsOf: url) + continuation.resume(returning: entity) + } catch { + continuation.resume(throwing: error) + } + } + } +} +``` + +--- + +## 🏗 Architecture Improvements (Priority: LOW) + +### 1. Implement Coordinator Pattern +**Recommendation:** +```swift +protocol Coordinator { + var navigationController: UINavigationController { get } + func start() +} + +class RoomsCoordinator: Coordinator { + let navigationController: UINavigationController + + func start() { + let viewModel = MyRoomsViewModel() + let vc = MyRoomsViewController(viewModel: viewModel) + vc.coordinator = self + navigationController.pushViewController(vc, animated: false) + } + + func showRoomDetail(url: URL) { + let vc = RoomViewerViewController(roomURL: url) + navigationController.pushViewController(vc, animated: true) + } +} +``` + +### 2. Dependency Injection +**Current State:** Singletons used everywhere (`UserManager.shared`, `SaveManager.shared`). + +**Recommendation:** +```swift +// Protocol-based injection +protocol UserManaging { + var currentUser: UserModel? { get } + func login(email: String, password: String) async throws -> UserModel +} + +class MyRoomsViewController: UIViewController { + private let userManager: UserManaging + private let saveManager: ModelSaving + + init(userManager: UserManaging = UserManager.shared, + saveManager: ModelSaving = SaveManager.shared) { + self.userManager = userManager + self.saveManager = saveManager + super.init(nibName: nil, bundle: nil) + } +} +``` + +### 3. Create a Design System +**Recommendation:** +``` +DesignSystem/ +├── Colors.swift # AppColors (already exists) +├── Typography.swift # AppFonts (already exists) +├── Spacing.swift # Consistent margins/padding +├── Shadows.swift # Reusable shadow styles +├── Animations.swift # Standard animation curves +└── Components/ + ├── PrimaryButton.swift + ├── SecondaryButton.swift + ├── Card.swift + ├── Badge.swift + └── Toast.swift +``` + +--- + +## 🔒 Security Recommendations (Priority: HIGH) + +### 1. Secure Storage +**Current State:** User data stored in plain `UserDefaults`. + +**Recommendation:** +```swift +// Use Keychain for sensitive data +import Security + +class KeychainManager { + static func save(key: String, data: Data) -> OSStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + return SecItemAdd(query as CFDictionary, nil) + } +} +``` + +### 2. Input Validation +**Current State:** Basic email/password validation exists. + +**Recommendation:** +- Add rate limiting for login attempts +- Implement password strength meter +- Sanitize all user inputs +- Validate file types before import + +### 3. Data Encryption +**Recommendation:** +```swift +// Encrypt sensitive files at rest +import CryptoKit + +func encryptData(_ data: Data, using key: SymmetricKey) throws -> Data { + let sealedBox = try AES.GCM.seal(data, using: key) + return sealedBox.combined! +} +``` + +--- + +## ♿ Accessibility Improvements (Priority: MEDIUM) + +### 1. VoiceOver Support +**Current State:** Limited accessibility labels. + +**Recommendation:** +```swift +// Add comprehensive accessibility +thumbnailView.isAccessibilityElement = true +thumbnailView.accessibilityLabel = "Room thumbnail" +thumbnailView.accessibilityHint = "Double tap to view room details" + +// For cells +cell.accessibilityLabel = "\(roomName), \(categoryName), created \(dateString)" +cell.accessibilityTraits = .button +``` + +### 2. Dynamic Type Support +**Current State:** Fixed font sizes used. + +**Recommendation:** +```swift +// Use scalable fonts +titleLabel.font = UIFont.preferredFont(forTextStyle: .headline) +titleLabel.adjustsFontForContentSizeCategory = true + +sizeLabel.font = UIFont.preferredFont(forTextStyle: .caption1) +sizeLabel.adjustsFontForContentSizeCategory = true +``` + +### 3. Color Contrast +**Recommendation:** +- Ensure all text meets WCAG AA standards (4.5:1 ratio) +- Add high contrast mode support +- Don't rely solely on color to convey information + +### 4. Reduce Motion +**Recommendation:** +```swift +if UIAccessibility.isReduceMotionEnabled { + // Use simpler animations or none + UIView.animate(withDuration: 0) { ... } +} else { + UIView.animate(withDuration: 0.3, + usingSpringWithDamping: 0.8, ...) { ... } +} +``` + +--- + +## 🧪 Testing Recommendations (Priority: HIGH) + +### 1. Unit Tests Needed +``` +EnvisionTests/ +├── Managers/ +│ ├── UserManagerTests.swift +│ ├── SaveManagerTests.swift +│ └── MetadataManagerTests.swift +├── Models/ +│ ├── UserModelTests.swift +│ ├── RoomMetadataTests.swift +│ └── FurnitureMetadataTests.swift +├── ViewModels/ +│ ├── RoomsViewModelTests.swift +│ └── FurnitureViewModelTests.swift +└── Extensions/ + ├── StringValidationTests.swift + └── UIColorHexTests.swift +``` + +### 2. UI Tests Needed +``` +EnvisionUITests/ +├── OnboardingFlowTests.swift +├── LoginFlowTests.swift +├── RoomsTabTests.swift +├── FurnitureTabTests.swift +└── ProfileTabTests.swift +``` + +### 3. Snapshot Tests +**Recommendation:** Use `swift-snapshot-testing` for UI regression testing. + +--- + +## 📁 Files to Add + +| File | Purpose | +|------|---------| +| `Services/NetworkManager.swift` | API networking layer | +| `Services/FirebaseService.swift` | Firebase integration | +| `Helpers/FileHelper.swift` | Common file operations | +| `Helpers/DateHelper.swift` | Date formatting utilities | +| `Components/LoadingOverlay.swift` | Reusable loading view | +| `Components/Toast.swift` | Unified toast component | +| `Components/EmptyStateView.swift` | Reusable empty state | +| `Components/FilterChipCell.swift` | Unified chip cell | +| `Protocols/ModelManaging.swift` | Common model protocols | +| `Errors/AppError.swift` | Unified error types | + +--- + +## 📁 Files to Remove/Refactor + +| File | Issue | Action | +|------|-------|--------| +| `ViewController.swift` | Empty/unused template | Delete | +| `PrimaryButton.swift` + `PrimaryButton1.swift` | Duplicate functionality | Merge into one | +| Large view controllers | Too much responsibility | Split into VM + Coordinator | + +--- + +## 📋 Implementation Priority + +### Phase 1 (Critical - 1-2 weeks) +1. ✅ Fix memory management issues +2. ✅ Add proper error handling +3. ✅ Implement Firebase Authentication +4. ✅ Add unit tests for managers + +### Phase 2 (Important - 2-4 weeks) +1. ✅ Refactor to MVVM +2. ✅ Create unified networking layer +3. ✅ Implement iCloud sync +4. ✅ Add accessibility support + +### Phase 3 (Enhancement - 4-6 weeks) +1. ✅ Add social features +2. ✅ Implement SharePlay +3. ✅ Add room comparison tool +4. ✅ Create subscription system + +### Phase 4 (Polish - Ongoing) +1. ✅ Performance optimizations +2. ✅ UI/UX refinements +3. ✅ Analytics integration +4. ✅ A/B testing infrastructure + +--- + +## 📊 Code Quality Metrics (Current) + +| Metric | Current | Target | +|--------|---------|--------| +| Test Coverage | 0% | 80%+ | +| Documentation | ~20% | 90%+ | +| Accessibility | ~10% | 100% | +| Max VC Lines | 1092 | <300 | +| Duplicate Code | ~15% | <5% | + +--- + +## 🔗 Recommended Libraries + +| Library | Purpose | Notes | +|---------|---------|-------| +| `Firebase` | Backend services | Auth, Firestore, Storage | +| `Kingfisher` | Image caching | Replace manual cache | +| `SnapKit` | Auto Layout DSL | Cleaner constraints | +| `Lottie` | Animations | Enhanced onboarding | +| `SwiftLint` | Code quality | Enforce style guide | +| `Quick/Nimble` | Testing | BDD-style tests | + +--- + +## 📝 Conclusion + +EnVision is a well-structured iOS app with solid AR/3D functionality. The main areas for improvement are: + +1. **Backend Integration** - Move from local storage to Firebase +2. **Architecture** - Adopt MVVM with Coordinators +3. **Testing** - Add comprehensive test coverage +4. **Accessibility** - Full VoiceOver and Dynamic Type support +5. **Performance** - Optimize thumbnail and model loading + +Implementing these improvements will result in a more maintainable, scalable, and user-friendly application. + +--- + +*Last Updated: January 1, 2026* +*Author: GitHub Copilot Analysis* diff --git a/PROJECT_WORKFLOW.md b/PROJECT_WORKFLOW.md new file mode 100644 index 0000000..3190ad8 --- /dev/null +++ b/PROJECT_WORKFLOW.md @@ -0,0 +1,302 @@ +# EnVision - Project Workflow Documentation + +## Overview +EnVision is an iOS AR/3D visualization app that enables users to: +- Scan and capture 3D models of furniture using photogrammetry +- Scan rooms using Apple's RoomPlan API +- Visualize and place furniture in rooms +- Manage saved 3D models and room scans + +--- + +## Project Structure + +``` +Envision/ +├── AppDelegate.swift # App lifecycle +├── SceneDelegate.swift # Window/Scene setup, navigation root +├── MainTabBarController.swift # Main tab bar with 3 tabs +├── ViewController.swift # Base view controller +│ +├── 3D_Models/ # Sample USDZ models +│ +├── Assets.xcassets/ # App assets (icons, images) +│ +├── Components/ # Reusable UI components +│ ├── CustomTextField.swift +│ ├── PrimaryButton.swift +│ └── PrimaryButton1.swift +│ +├── Extensions/ # Helper extensions +│ ├── Entity+Visit.swift # RealityKit entity traversal +│ ├── Extensions.swift # General extensions +│ ├── SaveManager.swift # Model saving/loading +│ ├── UIColor+Hex.swift # Hex color support +│ ├── UIFont+AppFonts.swift # Custom fonts +│ ├── UIViewController+Transition.swift +│ ├── UserManager.swift # User session management +│ └── UserModel.swift # User data model +│ +├── Managers/ +│ ├── BackgroundModelProcessor.swift # Background photogrammetry processing +│ └── TourManager.swift # App tour/tips management +│ +├── Tips/ # TipKit integration +│ ├── AppTips.swift +│ └── TipPresenter.swift +│ +└── Screens/ + ├── Onboarding/ # Login/signup flow + │ ├── SplashViewController.swift + │ ├── OnboardingController.swift + │ ├── OnboardingPage.swift + │ ├── LoginViewController.swift + │ ├── SignupViewController.swift + │ ├── ForgotPasswordViewController.swift + │ ├── ModernTextField.swift + │ └── SocialButton.swift + │ + └── MainTabs/ + ├── Rooms/ # Room scanning & visualization + │ ├── MyRoomsViewController.swift + │ ├── RoomCell.swift + │ ├── RoomModel.swift + │ ├── MetadataManager.swift + │ ├── RoomPlanScan/ + │ │ ├── RoomPlanScannerViewController.swift + │ │ └── RoomPreviewViewController.swift + │ └── furniture+room/ + │ ├── RoomViewerViewController.swift + │ ├── RoomEditVC.swift + │ ├── RoomVisualizeVC.swift + │ ├── FurniturePicker.swift + │ ├── FurnitureControlPanel.swift + │ └── OrbitJoystick.swift + │ + ├── furniture/ # Furniture/object capture + │ ├── ScanFurnitureViewController.swift + │ ├── FurnitureCategory.swift + │ ├── FurnitureCell.swift + │ ├── CreateModel/ + │ │ ├── CreateModelViewController.swift + │ │ └── CreateModelViewController2.swift + │ ├── ModelsFromFiles/ + │ │ ├── ViewModelsViewController.swift + │ │ └── USDZCell.swift + │ ├── Object Capture/ + │ │ ├── ObjectScanViewController.swift + │ │ ├── ObjectCapturePreviewController.swift # Photo preview & processing + │ │ ├── ARMeshExporter.swift + │ │ ├── ArrowGuideView.swift + │ │ ├── FeedbackBubble.swift + │ │ ├── InstructionOverlay.swift + │ │ └── ProgressRingView.swift + │ └── roomPlanColor/ + │ ├── RoomARView 1.swift + │ ├── RoomARView 2.swift + │ ├── RoomARWithFurnitureViewController.swift + │ └── VisualizeRoomViewController.swift + │ + └── profile/ # User settings + ├── ProfileViewController.swift + ├── ProfileCell.swift + ├── EditProfileViewController.swift + └── SubScreens/ + ├── AppearanceViewController.swift + ├── AppInfoViewController.swift + ├── EmailPasswordViewController.swift + ├── NotificationsViewController.swift + ├── PermissionsViewController.swift + ├── PrivacyControlsViewController.swift + ├── PrivacyPolicyViewController.swift + ├── TermsViewController.swift + └── TipsLibraryViewController.swift +``` + +--- + +## Application Flow + +### 1. App Launch +``` +AppDelegate → SceneDelegate → SplashViewController +``` + +### 2. Onboarding Flow +``` +SplashViewController + ↓ +OnboardingController (first launch) + ↓ +LoginViewController ←→ SignupViewController + ↔ ForgotPasswordViewController + ↓ +MainTabBarController +``` + +### 3. Main App (Tab Bar) +``` +MainTabBarController +├── Tab 1: My Rooms (MyRoomsViewController) +├── Tab 2: My Furniture (ScanFurnitureViewController) +└── Tab 3: Profile (ProfileViewController) +``` + +--- + +## Feature Workflows + +### Room Scanning Flow +``` +MyRoomsViewController + ↓ (Scan button) +RoomPlanScannerViewController (Uses RoomPlan API) + ↓ (Capture complete) +RoomPreviewViewController (Preview & save) + ↓ (Save) +MyRoomsViewController (Updated list) + ↓ (Select room) +RoomViewerViewController / RoomEditVC + ↓ (Add furniture) +FurniturePicker → RoomVisualizeVC +``` + +### Furniture/Object Capture Flow +``` +ScanFurnitureViewController + ↓ (Automatic Object Capture) +ObjectScanViewController (Camera capture) + ↓ (Photos captured) +ObjectCapturePreviewController + ↓ (Generate 3D Model - uses BackgroundModelProcessor) + ↓ (Processing happens in background) + ↓ (Model saved via SaveManager) +ScanFurnitureViewController (Updated list) + ↓ (View model) +QuickLook Preview +``` + +### Background Processing (Key Feature) +``` +ObjectCapturePreviewController + ↓ startProcessing() +BackgroundModelProcessor.shared.startProcessing() + ↓ + ├── Creates PhotogrammetrySession + ├── Registers UIBackgroundTask for extended processing + ├── Processes images → 3D model + ├── Updates progress via callbacks + ├── Sends local notification on completion + └── Saves model via SaveManager +``` + +--- + +## Key Components + +### BackgroundModelProcessor +**Purpose**: Enables 3D model generation to continue even when user leaves the screen. + +**Features**: +- Background task registration for extended processing +- Thread-safe progress tracking +- Callback-based UI updates +- Local notifications for completion +- Cancellation support + +**Usage**: +```swift +BackgroundModelProcessor.shared.startProcessing( + imagesFolder: imagesFolderURL, + detailLevel: .reduced +) { result in + switch result { + case .success(let savedURL): + // Model saved successfully + case .failure(let error): + // Handle error + } +} +``` + +### SaveManager +**Purpose**: Handles saving and loading 3D models and room data. + +**Locations**: +- Furniture: `Documents/Furniture/` +- Rooms: `Documents/Rooms/` +- Thumbnails: Cached separately + +### MetadataManager +**Purpose**: Manages room metadata (names, dates, categories). + +--- + +## Technologies Used + +- **RealityKit**: 3D rendering and AR +- **ARKit**: Augmented reality sessions +- **RoomPlan**: Room scanning (iOS 16+) +- **PhotogrammetrySession**: Object capture (iOS 17+) +- **QuickLook**: 3D model preview +- **TipKit**: User tips and tours + +--- + +## Error Fixed (January 27, 2026) + +### Issue +`PhotogrammetrySession.Request.Detail` enum in iOS 26 SDK doesn't have `.preview`, `.medium`, or `.full` members. + +### Files Modified +1. `ObjectCapturePreviewController.swift` + - Line 178: Changed `.preview` to `.reduced` + - Lines 227-233: Changed all detail levels to `.reduced` + +2. `BackgroundModelProcessor.swift` + - Wrapped async calls with `await MainActor.run { }` to fix Swift 6 concurrency warnings + +### Build Status +✅ BUILD SUCCEEDED + +--- + +## Notes for Future Development + +1. **Detail Levels**: The quality selector UI shows "Fast", "Balanced", "High Quality" but all map to `.reduced`. When newer SDK versions provide more options, update `updateQualityDescription()`. + +2. **Swift 6 Compatibility**: Main actor isolation warnings were fixed in BackgroundModelProcessor. Monitor other files with similar warnings. + +3. **Deprecated APIs**: Several iOS 26 deprecation warnings exist (UIScreen.main, UIBarButtonItem.Style.done). Address these for full iOS 26 compatibility. + +--- + +## Recent Updates (January 27, 2026) + +### Color Persistence Feature +Added the ability to save and restore colors when switching between Edit and Visualize modes. + +**New Files:** +- `Managers/RoomColorManager.swift` - Singleton manager for persisting room element colors + +**Modified Files:** +- `RoomEditVC.swift` - Now saves colors to RoomColorManager when changed +- `RoomVisualizeVC.swift` - Now loads and applies saved colors when loading room + +**How It Works:** +1. User changes color in Edit mode using the color picker +2. Color is automatically saved to `Documents/RoomColors/{roomName}_colors.json` +3. When switching to Visualize mode, saved colors are loaded and applied +4. Colors persist across app restarts + +**Supported Element Types:** +- Walls, Floors, Doors, Windows, Tables, Chairs, Storage + +### Color Picker UI Improvements +- Added native "Cancel" and "Done" buttons to the color picker navigation bar +- Cancel button restores previous colors +- Done button confirms the color selection + +### Material Resolution Warnings +The warnings about "Could not resolve material name 'engine:BuiltinRenderGraphResources/AR/...'" are RealityKit internal warnings in the iOS Simulator. They don't affect functionality and typically don't appear on physical devices. + diff --git a/RECOMMENDED_IMPROVEMENTS.md b/RECOMMENDED_IMPROVEMENTS.md new file mode 100644 index 0000000..2518767 --- /dev/null +++ b/RECOMMENDED_IMPROVEMENTS.md @@ -0,0 +1,348 @@ +# EnVision - Recommended Improvements + +## 🔴 Critical (High Priority) + +### 1. ✅ Quality Selector Fixed +**Status**: Fixed in this session + +The quality selector was showing 3 options that all mapped to `.reduced`. Updated to show single "Standard Quality" option since iOS 26 SDK only supports `.reduced` detail level. + +--- + +### 2. Background Processing Persistence +**Current Issue**: If app is force-quit during processing, progress is lost. + +**Solution**: Add persistent job queue with Core Data or file-based storage. + +```swift +// Add to BackgroundModelProcessor.swift +private func persistJobState() { + guard let job = currentJob else { return } + let encoder = JSONEncoder() + if let data = try? encoder.encode(job) { + UserDefaults.standard.set(data, forKey: "currentProcessingJob") + } +} + +func resumePersistedJob() -> ProcessingJob? { + guard let data = UserDefaults.standard.data(forKey: "currentProcessingJob"), + let job = try? JSONDecoder().decode(ProcessingJob.self, from: data) else { + return nil + } + return job +} +``` + +--- + +### 3. Memory Management for Large Image Sets +**Current Issue**: Loading 100+ images can cause memory pressure. + +**Solution**: Implement lazy loading with image downsampling. + +```swift +// Add to ObjectCapturePreviewController.swift +private func downsampledImage(at url: URL, to pointSize: CGSize) -> UIImage? { + let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else { return nil } + + let maxDimensionInPixels = max(pointSize.width, pointSize.height) * UIScreen.main.scale + let downsampleOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels + ] as CFDictionary + + guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else { return nil } + return UIImage(cgImage: downsampledImage) +} +``` + +--- + +## 🟡 Important (Medium Priority) + +### 4. Add Processing Time Estimation +Show users estimated time remaining based on image count. + +```swift +// Add to BackgroundModelProcessor.swift +func estimatedProcessingTime(imageCount: Int) -> String { + // Rough estimates based on .reduced detail + let baseTime: Double = 30 // Base seconds + let perImageTime: Double = 0.5 // Additional seconds per image + let totalSeconds = baseTime + (Double(imageCount) * perImageTime) + + if totalSeconds < 60 { + return "~\(Int(totalSeconds)) seconds" + } else { + return "~\(Int(totalSeconds / 60)) minutes" + } +} +``` + +--- + +### 5. Add Cancel Button During Processing +Users should be able to cancel ongoing processing. + +```swift +// Add cancel button to ObjectCapturePreviewController +private let cancelButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("Cancel", for: .normal) + btn.setTitleColor(.systemRed, for: .normal) + btn.isHidden = true + btn.translatesAutoresizingMaskIntoConstraints = false + return btn +}() + +@objc private func cancelProcessing() { + let alert = UIAlertController( + title: "Cancel Processing?", + message: "This will stop the 3D model generation. Photos will be preserved.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Continue", style: .cancel)) + alert.addAction(UIAlertAction(title: "Cancel", style: .destructive) { _ in + self.processor.cancelProcessing() + self.handleError(message: "Processing cancelled") + }) + present(alert, animated: true) +} +``` + +--- + +### 6. Improve Error Messages +Make error messages user-friendly with recovery suggestions. + +```swift +// Add to BackgroundModelProcessor.swift +enum ProcessingError: LocalizedError { + case alreadyProcessing + case processingFailed(String) + case sessionCreationFailed + case insufficientImages + case lowQualityImages + + var errorDescription: String? { + switch self { + case .alreadyProcessing: + return "A model is already being processed" + case .processingFailed(let message): + return message + case .sessionCreationFailed: + return "Could not start the 3D capture session" + case .insufficientImages: + return "Not enough photos captured" + case .lowQualityImages: + return "Image quality too low for 3D reconstruction" + } + } + + var recoverySuggestion: String? { + switch self { + case .alreadyProcessing: + return "Wait for the current model to finish or cancel it." + case .insufficientImages: + return "Capture at least 20 photos from different angles." + case .lowQualityImages: + return "Ensure good lighting and hold the camera steady." + default: + return "Try capturing new photos in better conditions." + } + } +} +``` + +--- + +### 7. Add Progress Persistence Across App Launches +Show processing history and allow resuming failed jobs. + +```swift +// Create ProcessingHistoryManager.swift +class ProcessingHistoryManager { + static let shared = ProcessingHistoryManager() + + private let historyKey = "processingHistory" + + func saveJob(_ job: ProcessingJob) { + var history = getHistory() + history.append(job) + // Keep last 20 jobs + if history.count > 20 { + history.removeFirst(history.count - 20) + } + if let data = try? JSONEncoder().encode(history) { + UserDefaults.standard.set(data, forKey: historyKey) + } + } + + func getHistory() -> [ProcessingJob] { + guard let data = UserDefaults.standard.data(forKey: historyKey), + let history = try? JSONDecoder().decode([ProcessingJob].self, from: data) else { + return [] + } + return history + } +} +``` + +--- + +## 🟢 Nice to Have (Low Priority) + +### 8. Add Haptic Feedback Throughout Processing +Provide tactile feedback at key milestones. + +```swift +// Add milestone haptics +private func provideMilestoneHaptic(at progress: Float) { + let milestones: [Float] = [0.25, 0.5, 0.75, 1.0] + if milestones.contains(where: { abs($0 - progress) < 0.01 }) { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(progress >= 1.0 ? .success : .warning) + } +} +``` + +--- + +### 9. Add Model Preview Before Saving +Show a quick 3D preview before final save. + +```swift +// Add to ObjectCapturePreviewController after processing +private func showModelPreview(at url: URL) { + let previewController = QLPreviewController() + previewController.dataSource = self + self.previewModelURL = url + present(previewController, animated: true) +} +``` + +--- + +### 10. Add Share Functionality +Allow sharing generated models directly. + +```swift +private func shareModel(at url: URL) { + let activityVC = UIActivityViewController( + activityItems: [url], + applicationActivities: nil + ) + present(activityVC, animated: true) +} +``` + +--- + +### 11. Add iCloud Sync for Models +Sync furniture models across devices. + +```swift +// In SaveManager.swift +private func iCloudContainerURL() -> URL? { + FileManager.default.url(forUbiquityContainerIdentifier: nil)? + .appendingPathComponent("Documents") + .appendingPathComponent("Furniture") +} +``` + +--- + +### 12. Add Analytics/Telemetry +Track processing success rates and common failure points. + +```swift +struct ProcessingAnalytics { + static func trackProcessingStarted(imageCount: Int, detailLevel: String) { + // Log to analytics service + } + + static func trackProcessingCompleted(duration: TimeInterval, success: Bool) { + // Log to analytics service + } +} +``` + +--- + +## 📱 UI/UX Improvements + +### 13. Add Processing Animation +Replace spinner with custom animation showing model being built. + +### 14. Add Photo Quality Indicators +Show which photos are good/bad quality before processing. + +### 15. Add Guided Capture Mode +Step-by-step instructions for capturing optimal photos. + +### 16. Add Batch Processing +Queue multiple objects for processing overnight. + +--- + +## 🔧 Code Quality Improvements + +### 17. Extract Constants +```swift +struct ProcessingConstants { + static let minPhotos = 20 + static let optimalPhotos = 50 + static let maxPhotos = 200 + static let supportedExtensions = ["jpg", "jpeg", "heic", "png"] +} +``` + +### 18. Add Unit Tests +```swift +class BackgroundModelProcessorTests: XCTestCase { + func testProcessingStateManagement() { + let processor = BackgroundModelProcessor.shared + XCTAssertFalse(processor.isProcessing) + } + + func testEstimatedTime() { + let time = processor.estimatedProcessingTime(imageCount: 50) + XCTAssertTrue(time.contains("minute")) + } +} +``` + +### 19. Add Documentation +Add comprehensive code documentation with usage examples. + +--- + +## Implementation Priority + +| Priority | Improvement | Effort | Impact | +|----------|------------|--------|--------| +| 1 | ✅ Fix quality selector | Low | High | +| 2 | Add cancel button | Low | High | +| 3 | Memory management | Medium | High | +| 4 | Time estimation | Low | Medium | +| 5 | Better error messages | Low | Medium | +| 6 | Job persistence | Medium | Medium | +| 7 | Model preview | Medium | Medium | +| 8 | Share functionality | Low | Low | +| 9 | iCloud sync | High | Medium | +| 10 | Analytics | Medium | Low | + +--- + +## Quick Wins (Can Implement Now) + +1. ✅ Quality selector fix - Done +2. Cancel button - 15 minutes +3. Time estimation - 10 minutes +4. Better error messages - 20 minutes +5. Haptic feedback - 10 minutes + +Would you like me to implement any of these improvements? diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md new file mode 100644 index 0000000..e448d56 --- /dev/null +++ b/TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,1465 @@ +# EnVision - Complete Technical Documentation + +> **A comprehensive technical guide to the EnVision iOS application architecture, workflows, and implementation details.** + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [App Architecture](#2-app-architecture) +3. [App Launch Flow](#3-app-launch-flow) +4. [Tab 1: My Rooms](#4-tab-1-my-rooms) +5. [Tab 2: My Furniture](#5-tab-2-my-furniture) +6. [Tab 3: Profile](#6-tab-3-profile) +7. [Onboarding Flow](#7-onboarding-flow) +8. [Data Models](#8-data-models) +9. [Extensions & Utilities](#9-extensions--utilities) +10. [Components](#10-components) +11. [3D/AR Features](#11-3dar-features) +12. [File Structure Reference](#12-file-structure-reference) + +--- + +## 1. Project Overview + +**EnVision** is an iOS application that leverages Apple's RoomPlan and Object Capture technologies to: +- Scan rooms and create 3D models +- Capture furniture as 3D objects +- Visualize and edit 3D models with custom colors +- Measure dimensions in 3D space +- Manage user profiles and preferences + +### Tech Stack +| Technology | Usage | +|------------|-------| +| **UIKit** | Primary UI framework | +| **RoomPlan** | Room scanning with LiDAR | +| **ARKit** | Augmented reality features | +| **RealityKit** | 3D model rendering | +| **Object Capture** | Photogrammetry for furniture | +| **QuickLook** | 3D model preview | +| **UserDefaults** | Local data persistence | + +### Requirements +- iOS 16.0+ +- Xcode 15.0+ +- iPhone with LiDAR sensor (for scanning features) +- A12 Bionic chip or later + +--- + +## 2. App Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AppDelegate │ +│ │ │ +│ ▼ │ +│ SceneDelegate │ +│ │ │ +│ ▼ │ +│ SplashViewController │ +│ │ │ +│ ▼ │ +│ OnboardingController │ +│ │ │ +│ ▼ │ +│ LoginViewController │ +│ │ │ +│ ▼ │ +│ MainTabBarController │ +│ ┌─────────┼─────────┐ │ +│ ▼ ▼ ▼ │ +│ MyRooms MyFurniture Profile │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Design Patterns Used +- **Singleton**: `UserManager`, `SaveManager`, `MetadataManager` +- **Delegation**: Collection views, document pickers, AR sessions +- **Extensions**: Organized helper methods in separate files +- **MVC**: View Controllers with model separation + +--- + +## 3. App Launch Flow + +### 3.1 AppDelegate.swift +**Location**: `Envision/AppDelegate.swift` + +```swift +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_:didFinishLaunchingWithOptions:) -> Bool + func application(_:configurationForConnecting:options:) -> UISceneConfiguration +} +``` + +| Method | Purpose | +|--------|---------| +| `application(_:didFinishLaunchingWithOptions:)` | App initialization, returns `true` | +| `application(_:configurationForConnecting:options:)` | Returns scene configuration for multi-window support | + +### 3.2 SceneDelegate.swift +**Location**: `Envision/SceneDelegate.swift` + +```swift +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_:willConnectTo:options:) // Initial setup + func switchToMainApp() // Navigate to main tabs + func switchToLogin() // Navigate to login +} +``` + +| Method | Purpose | Called When | +|--------|---------|-------------| +| `scene(_:willConnectTo:options:)` | Sets up window with `SplashViewController`, applies saved theme | App launches | +| `switchToMainApp()` | Replaces root with `MainTabBarController` | After successful login | +| `switchToLogin()` | Replaces root with `LoginViewController` | After logout | + +**Theme Handling:** +```swift +// Reads saved theme preference +if let saved = UserDefaults.standard.object(forKey: "selectedTheme") as? Int { + switch saved { + case 0: style = .light + case 1: style = .dark + default: style = .unspecified // System + } +} +``` + +### 3.3 MainTabBarController.swift +**Location**: `Envision/MainTabBarController.swift` + +```swift +final class MainTabBarController: UITabBarController { + override func viewDidLoad() + private func setupTabs() + private func setupLiquidGlassEffect() +} +``` + +**Tab Configuration:** +| Tab | View Controller | Icon (Normal) | Icon (Selected) | +|-----|-----------------|---------------|-----------------| +| My Rooms | `MyRoomsViewController` | `house` | `house.fill` | +| My Furniture | `ScanFurnitureViewController` | `sofa.viewfinder` | `custom.sofafill.viewfinder` | +| Profile | `ProfileViewController` | `person` | `person.fill` | + +**Liquid Glass Effect:** +- Transparent background with blur effect +- Rounded corners (30pt radius) +- Subtle shadow for depth + +--- + +## 4. Tab 1: My Rooms + +### 4.1 Overview +The My Rooms tab displays scanned and imported room models in a grid layout with filtering, search, and management capabilities. + +### 4.2 File Structure +``` +Screens/MainTabs/Rooms/ +├── MyRoomsViewController.swift # Main controller (726 lines) +├── MyRoomsViewController+helpers.swift # Extensions (382 lines) +├── RoomCell.swift # Collection view cell +├── RoomCategory.swift # Category/Type enums +├── RoomModel.swift # Data model +├── MetadataManager.swift # Metadata persistence +├── furniture+room/ # Room viewing/editing +│ ├── RoomViewerViewController.swift +│ ├── RoomVisualizeVC.swift +│ ├── RoomEditVC.swift +│ ├── FurniturePicker.swift +│ ├── FurnitureControlPanel.swift +│ └── OrbitJoystick.swift +└── RoomPlanScan/ + ├── RoomPlanScannerViewController.swift + └── RoomPreviewViewController.swift +``` + +### 4.3 MyRoomsViewController.swift + +#### Properties +```swift +final class MyRoomsViewController: UIViewController { + // MARK: - UI + var collectionView: UICollectionView! + private var loadingOverlay: UIVisualEffectView! + private var activityIndicator: UIActivityIndicatorView! + private var loadingLabel: UILabel! + private let searchController = UISearchController() + private var refreshControl: UIRefreshControl! + var previewURL: URL! + private var emptyStateView: UIView! + + // MARK: - Data + var roomFiles: [URL] = [] + var selectedCategory: RoomCategory? + var selectedRoomType: RoomType? + let thumbnailCache = NSCache() + var isSelectionMode = false +} +``` + +#### Key Methods + +| Method | Line | Purpose | +|--------|------|---------| +| `viewDidLoad()` | ~56 | Initialize UI, clean metadata, load files | +| `setupUI()` | ~63 | Call all setup methods | +| `setupNavigationBar()` | ~74 | Create scan/import buttons, menu | +| `setupSearch()` | ~97 | Configure search controller | +| `setupCollectionView()` | ~106 | Create compositional layout | +| `setupEmptyState()` | ~538 | Create empty state UI | +| `loadRoomFiles()` | ~342 | Load USDZ files from documents | +| `importRoomFiles(_:)` | ~366 | Handle imported files | +| `scanTapped()` | ~229 | Navigate to RoomPlan scanner | +| `chipTapped(_:)` | ~236 | Handle filter chip selection | +| `enableMultipleSelection()` | ~255 | Enter multi-select mode | +| `deleteSelectedRooms()` | ~285 | Batch delete selected | +| `generateThumbnail(for:completion:)` | ~480 | Create/cache thumbnails | +| `fileSizeString(for:)` | ~506 | Format file size | +| `fileDateString(for:)` | ~516 | Format creation date | + +#### UI Layout +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Navigation Bar │ +│ [☰ Menu] [Import ↓] [Scan 📷] │ +├─────────────────────────────────────────────────────────────────┤ +│ 🔍 Search room models... │ +├─────────────────────────────────────────────────────────────────┤ +│ Section 0: Filter Chips (horizontal scroll) │ +│ [All (5)] [Parametric (3)] [Textured (2)] [Living Room] ... │ +├─────────────────────────────────────────────────────────────────┤ +│ Section 1: Room Grid │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ [Thumbnail Image] [Parametric] [Bedroom] │ │ +│ │ │ │ +│ │ Room Name (without extension) │ │ +│ │ 2.4 MB │ │ +│ │ Jan 1, 2026 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ (repeats for each room...) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Collection View Layout +```swift +// Compositional layout with 2 sections +private func setupCollectionView() { + let layout = UICollectionViewCompositionalLayout { section, _ in + section == 0 ? self.makeChipsSection() : self.makeRoomsSection() + } +} + +// Chips: Horizontal scroll, estimated width +private func makeChipsSection() -> NSCollectionLayoutSection { + // orthogonalScrollingBehavior = .continuous + // Height: 32pt, Spacing: 8pt +} + +// Rooms: Full width cards, 200pt height +private func makeRoomsSection() -> NSCollectionLayoutSection { + // 1 column on iPhone, 4 columns on iPad + // Height: 200pt +} +``` + +### 4.4 MyRoomsViewController+helpers.swift + +#### Protocol Conformances +```swift +extension MyRoomsViewController: UIDocumentPickerDelegate +extension MyRoomsViewController: UICollectionViewDataSource, UICollectionViewDelegate +extension MyRoomsViewController: UISearchResultsUpdating +extension MyRoomsViewController: QLPreviewControllerDataSource +extension MyRoomsViewController: UICollectionViewDataSourcePrefetching +``` + +#### Key Methods + +| Method | Purpose | +|--------|---------| +| `documentPicker(_:didPickDocumentsAt:)` | Handle imported files | +| `numberOfSections(in:)` | Returns 2 (chips + rooms) | +| `collectionView(_:numberOfItemsInSection:)` | Chips count or filtered files count | +| `collectionView(_:cellForItemAt:)` | Configure ChipCell or RoomCell | +| `collectionView(_:didSelectItemAt:)` | Open room viewer or handle selection | +| `collectionView(_:contextMenuConfigurationForItemAt:)` | Long-press menu | +| `collectionView(_:prefetchItemsAt:)` | Prefetch thumbnails for performance | +| `quickLook(url:)` | Show QuickLook preview | +| `showEditCategoryDialog(for:currentMetadata:)` | Category picker | +| `showEditRoomTypeDialog(for:currentMetadata:)` | Room type picker | +| `showRenameDialog(for:)` | Rename room | +| `shareRoom(url:)` | Share via activity sheet | +| `confirmDelete(url:)` | Delete with confirmation | + +#### Context Menu Actions +```swift +UIMenu(children: [ + UIAction(title: "View in AR", image: "arkit") { self?.quickLook(url: url) }, + UIAction(title: "Edit Category", image: "tag") { ... }, + UIAction(title: "Edit Room Type", image: "cube") { ... }, + UIAction(title: "Rename", image: "pencil") { ... }, + UIAction(title: "Share", image: "square.and.arrow.up") { ... }, + UIAction(title: "Delete", image: "trash", attributes: .destructive) { ... } +]) +``` + +### 4.5 RoomCell.swift + +#### Properties +```swift +final class RoomCell: UICollectionViewCell { + static let reuseID = "RoomCell" + + private let thumbnailView: UIImageView + private let titleLabel: UILabel + private let sizeLabel: UILabel + private let dateLabel: UILabel + private let container: UIView + private let selectionCircle: UIImageView + private let categoryBadge: UIView + private let roomTypeBadge: UIView +} +``` + +#### Configure Method +```swift +func configure( + fileName: String, // Displays without extension + size: String, // e.g., "2.4 MB" + dateText: String, // e.g., "Jan 1, 2026" + thumbnail: UIImage?, + category: RoomCategory?, // Shows badge if set + roomType: RoomType? // Shows badge if set +) +``` + +### 4.6 RoomCategory.swift + +```swift +enum RoomCategory: String, Codable, CaseIterable { + case livingRoom = "Living Room" // 🛋️ Orange + case bedroom = "Bedroom" // 🛏️ Purple + case studyRoom = "Study Room" // 📚 Blue + case office = "Office" // 💼 Green + case other = "Other" // ❓ Gray + + var sfSymbol: String { ... } + var color: UIColor { ... } + var displayName: String { rawValue } +} + +enum RoomType: String, Codable, CaseIterable { + case parametric = "Parametric" // RoomPlan API, Teal + case textured = "Textured" // Object Capture, Pink + + var sfSymbol: String { ... } + var color: UIColor { ... } + var description: String { ... } +} +``` + +### 4.7 MetadataManager.swift + +```swift +class MetadataManager { + static let shared = MetadataManager() + + // Core Methods + func loadMetadata() -> RoomsMetadata + func saveMetadata(_ metadata: RoomsMetadata) + func getMetadata(for filename: String) -> RoomMetadata? + func updateMetadata(for filename: String, metadata: RoomMetadata) + func deleteMetadata(for filename: String) + func renameMetadata(from oldFilename: String, to newFilename: String) + func cleanupOrphanedMetadata() +} +``` + +**Storage Location**: `Documents/roomPlan/rooms_metadata.json` + +### 4.8 Room Scanning Flow + +``` +┌──────────────────────┐ +│ MyRoomsViewController │ +│ │ +│ [Scan Button] │ +└──────────┬───────────┘ + │ scanTapped() + ▼ +┌──────────────────────────────┐ +│ RoomPlanScannerViewController │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ RoomCaptureView │ │ +│ │ (Apple's UI) │ │ +│ └──────────────────────────┘ │ +│ │ +│ [Save Button] │ +└──────────────┬───────────────┘ + │ saveTapped() + ▼ +┌──────────────────────────┐ +│ RoomPreviewViewController │ +│ │ +│ - Preview captured room │ +│ - Enter room name │ +│ - Select category │ +│ - Export as USDZ │ +└──────────────┬───────────┘ + │ Save & Export + ▼ +┌──────────────────────┐ +│ MyRoomsViewController │ +│ │ +│ loadRoomFiles() │ +│ (refreshes grid) │ +└──────────────────────┘ +``` + +### 4.9 Room Viewing Flow + +``` +┌──────────────────────┐ +│ MyRoomsViewController │ +│ │ +│ [Tap on Room Cell] │ +└──────────┬───────────┘ + │ didSelectItemAt + ▼ +┌─────────────────────────────┐ +│ RoomViewerViewController │ +│ │ +│ [Visualize] [Edit] │ ← Segmented Control +│ │ +└──────────┬──────────────────┘ + │ + ┌─────┴─────┐ + ▼ ▼ +┌──────────┐ ┌──────────┐ +│Visualize │ │ Edit │ +│ VC │ │ VC │ +└──────────┘ └──────────┘ +``` + +### 4.10 RoomVisualizeVC.swift + +#### Properties +```swift +final class RoomVisualizeVC: UIViewController { + private let roomURL: URL + private var roomModel: ModelEntity? + private var displayedModel: ModelEntity? + private var placedFurniture: [ModelEntity] = [] + + // Measurement + private var isMeasuringMode = false + private var measurementPoints: [SIMD3] = [] + private var measurementLabel: UILabel? + private var measurementLine: ModelEntity? + + // Camera + private let orbitCamera = PerspectiveCamera() + private var cameraPitch: Float = .pi / 6 + private var cameraYaw: Float = .pi / 4 + private var cameraDistance: Float = 1.5 + + private let arView: ARView // Non-AR mode +} +``` + +#### Key Features +1. **3D Orbit Camera**: Pan and pinch to rotate/zoom +2. **Ruler Tool**: Tap two points to measure distance +3. **Add Furniture**: Place furniture models in the room +4. **Control Panel**: Adjust furniture scale, rotation, position + +#### Measurement Flow +``` +[Tap Ruler Button] → isMeasuringMode = true + │ + ▼ +[Show Instructions Toast] + │ + ▼ +[Tap First Point] → Add orange sphere marker + │ + ▼ +[Tap Second Point] → Add second marker + │ │ + ▼ ▼ +[Draw Line Between] [Calculate Distance] + │ │ + ▼ ▼ +[Show Distance Label: "📏 1.5 m (4.9 ft)"] +``` + +### 4.11 RoomEditVC.swift + +#### Key Features +1. **Color Picker**: Change colors of walls, floors, doors, windows, etc. +2. **Labels Toggle**: Show/hide entity labels +3. **Add Furniture**: Same as Visualize mode +4. **Floating Menu**: Quick access to all editing tools + +#### Color Targets +```swift +enum ColorTarget { + case walls + case doors + case tables + case floors + case windows + case storage + case selected +} +``` + +--- + +## 5. Tab 2: My Furniture + +### 5.1 Overview +The My Furniture tab manages 3D furniture models captured via Object Capture or imported as USDZ files. + +### 5.2 File Structure +``` +Screens/MainTabs/furniture/ +├── ScanFurnitureViewController.swift # Main controller (1092 lines) +├── FurnitureCell.swift # Collection view cell +├── FurnitureCategory.swift # Category enum +├── CreateModel/ +│ ├── CreateModelViewController.swift +│ └── CreateModelViewController2.swift +├── ModelsFromFiles/ +│ ├── USDZCell.swift +│ └── ViewModelsViewController.swift +├── Object Capture/ +│ ├── ObjectScanViewController.swift +│ ├── ObjectCapturePreviewController.swift +│ ├── ARMeshExporter.swift +│ ├── ArrowGuideView.swift +│ ├── FeedbackBubble.swift +│ ├── InstructionOverlay.swift +│ └── ProgressRingView.swift +└── roomPlanColor/ + ├── VisualizeRoomViewController.swift + ├── RoomARWithFurnitureViewController.swift + └── RoomARView 1.swift / 2.swift +``` + +### 5.3 ScanFurnitureViewController.swift + +#### Properties +```swift +final class ScanFurnitureViewController: UIViewController { + // MARK: - UI + private var collectionView: UICollectionView! + private var loadingOverlay: UIVisualEffectView! + private var emptyStateView: UIView! + private let searchController = UISearchController() + private var refreshControl: UIRefreshControl! + + // MARK: - Data + private var furnitureFiles: [URL] = [] + private var filteredFiles: [URL] = [] + private var selectedCategory: FurnitureCategory? = nil + private let thumbnailCache: NSCache = .init() + private var previewURL: URL? +} +``` + +#### Key Methods + +| Method | Line | Purpose | +|--------|------|---------| +| `viewDidLoad()` | ~104 | Initialize UI | +| `setupNavigationBar()` | ~139 | Scan menu, import button | +| `setupCollectionView()` | ~337 | Compositional layout | +| `setupEmptyState()` | ~488 | Empty state UI | +| `loadFurnitureFiles(from:)` | ~544 | Load USDZ from documents | +| `generateThumbnail(for:completion:)` | ~592 | Create/cache thumbnails | +| `automaticCaptureTapped()` | ~618 | Open ObjectScanViewController | +| `createFromPhotosTapped()` | ~623 | Open CreateModelViewController | +| `importUSDZTapped()` | ~628 | Open document picker | +| `getCategoryForURL(_:)` | ~66 | Get/infer category | +| `inferCategory(from:)` | ~75 | Keyword-based inference | +| `chipTapped(at:)` | ~786 | Category filter | +| `showQuickLook(url:)` | ~833 | Open QL preview | +| `renameModel(at:url:)` | ~841 | Edit dialog | +| `deleteModel(at:url:)` | ~936 | Delete confirmation | + +#### UI Layout +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Navigation Bar │ +│ [☰ Menu] [Import ↓] [Scan ▾] │ +├─────────────────────────────────────────────────────────────────┤ +│ 🔍 Search models... │ +├─────────────────────────────────────────────────────────────────┤ +│ Section 0: Category Chips │ +│ [All (8)] [Chairs] [Tables] [Storage] [Beds] [Lighting] ... │ +├─────────────────────────────────────────────────────────────────┤ +│ Section 1: Furniture Grid (2 columns) │ +│ ┌───────────────┐ ┌───────────────┐ │ +│ │ [Thumbnail] │ │ [Thumbnail] │ │ +│ │ │ │ │ │ +│ │ Chair Model │ │ Table Model │ │ +│ │ 1.2 MB │ │ 3.4 MB │ │ +│ │ Jan 1, 2026 │ │ Dec 28, 2025 │ │ +│ └───────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Scan Menu Options +```swift +let scanMenu = UIMenu(children: [ + UIAction(title: "Automatic Object Capture", + image: "camera.metering.center.weighted") { + self.automaticCaptureTapped() // → ObjectScanViewController + }, + UIAction(title: "Create From Photos", + image: "photo.on.rectangle.angled") { + self.createFromPhotosTapped() // → CreateModelViewController + } +]) +``` + +### 5.4 FurnitureCell.swift + +```swift +final class FurnitureCell: UICollectionViewCell { + static let reuseIdentifier = "FurnitureCell" + + private let container: UIView + private let thumbnailImageView: UIImageView + private let nameLabel: UILabel + private let sizeLabel: UILabel + private let dateLabel: UILabel + private let selectionOverlay: UIView + private let checkmarkImageView: UIImageView + private let placeholderIcon: UIImageView + + func configure(name: String, sizeText: String, dateText: String, thumbnail: UIImage?) +} +``` + +### 5.5 FurnitureCategory.swift + +```swift +enum FurnitureCategory: String, Codable, CaseIterable { + case seating = "Chairs" // 🪑 Blue + case tables = "Tables" // 🪑 Orange + case storage = "Storage" // 🗄️ Purple + case beds = "Beds" // 🛏️ Indigo + case lighting = "Lighting" // 💡 Yellow + case decor = "Decor" // 🖼️ Pink + case kitchen = "Kitchen" // 🍳 Teal + case outdoor = "Outdoor" // 🌳 Green + case office = "Office" // 💻 Brown + case electronics = "Electronics" // 📺 Cyan + case other = "Other" // 📦 Gray + + var sfSymbol: String { ... } + var icon: String { sfSymbol } + var color: UIColor { ... } + var displayName: String { rawValue } +} +``` + +#### Category Inference +```swift +private func inferCategory(from name: String) -> FurnitureCategory { + let lowercased = name.lowercased() + + if lowercased.contains("chair") || lowercased.contains("sofa") { return .seating } + if lowercased.contains("table") || lowercased.contains("desk") { return .tables } + if lowercased.contains("cabinet") || lowercased.contains("shelf") { return .storage } + if lowercased.contains("bed") { return .beds } + if lowercased.contains("lamp") || lowercased.contains("light") { return .lighting } + // ... more patterns + + return .other +} +``` + +### 5.6 Object Capture Flow + +``` +┌────────────────────────────┐ +│ ScanFurnitureViewController │ +│ │ +│ [Scan ▾] → "Automatic" │ +└─────────────┬──────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ ObjectScanViewController │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ Camera Preview │ │ +│ │ │ │ +│ │ 📸 Auto-capture timer │ │ +│ │ │ │ +│ └─────────────────────────────┘ │ +│ │ +│ Photos: [42] "Keep going..." │ +│ │ +│ [Finish Capture] │ +└─────────────┬───────────────────┘ + │ stopCapture() + ▼ +┌─────────────────────────────────────┐ +│ ObjectCapturePreviewController │ +│ │ +│ Processing photos... │ +│ ████████████░░░░ 75% │ +│ │ +│ → PhotogrammetrySession │ +│ → Generate 3D model │ +│ → Export as USDZ │ +└─────────────┬───────────────────────┘ + │ + ▼ +┌────────────────────────────┐ +│ ScanFurnitureViewController │ +│ │ +│ loadFurnitureFiles() │ +│ (refreshes grid) │ +└────────────────────────────┘ +``` + +--- + +## 6. Tab 3: Profile + +### 6.1 Overview +The Profile tab manages user settings, preferences, and account information. + +### 6.2 File Structure +``` +Screens/MainTabs/profile/ +├── ProfileViewController.swift +├── ProfileCell.swift +├── EditProfileViewController.swift +└── SubScreens/ + ├── AppearanceViewController.swift + ├── NotificationsViewController.swift + ├── PrivacyControlsViewController.swift + ├── PermissionsViewController.swift + ├── EmailPasswordViewController.swift + ├── AppInfoViewController.swift + ├── TermsViewController.swift + └── PrivacyPolicyViewController.swift +``` + +### 6.3 ProfileViewController.swift + +#### UI Layout +``` +┌─────────────────────────────────────────────┐ +│ Profile │ +├─────────────────────────────────────────────┤ +│ ┌───────────┐ │ +│ │ Avatar │ │ +│ └───────────┘ │ +│ Shaurya │ +│ shaurya@gmail.com │ +│ [Edit Profile] │ +├─────────────────────────────────────────────┤ +│ ACCOUNT │ +│ 👤 My Profile ▶ │ +│ ✉️ Email & Password ▶ │ +├─────────────────────────────────────────────┤ +│ PREFERENCES │ +│ 🎨 Appearance ▶ │ +│ 🔔 Notifications ▶ │ +├─────────────────────────────────────────────┤ +│ PRIVACY & SECURITY │ +│ 🔒 Privacy Controls ▶ │ +│ ✋ Permissions ▶ │ +├─────────────────────────────────────────────┤ +│ ABOUT │ +│ ℹ️ App Info ▶ │ +│ 📄 Terms of Service ▶ │ +│ 🛡️ Privacy Policy ▶ │ +├─────────────────────────────────────────────┤ +│ 🚪 Sign Out (red) │ +├─────────────────────────────────────────────┤ +│ Version 1.0 (1) │ +└─────────────────────────────────────────────┘ +``` + +#### Sections Enum +```swift +private enum Section: Int, CaseIterable { + case account + case preferences + case privacy + case about + case logout +} +``` + +#### Navigation Mapping + +| Section | Item | Destination | +|---------|------|-------------| +| Account | My Profile | `EditProfileViewController` (modal) | +| Account | Email & Password | `EmailPasswordViewController` | +| Preferences | Appearance | `AppearanceViewController` | +| Preferences | Notifications | `NotificationsViewController` | +| Privacy | Privacy Controls | `PrivacyControlsViewController` | +| Privacy | Permissions | `PermissionsViewController` | +| About | App Info | `AppInfoViewController` | +| About | Terms of Service | `TermsViewController` | +| About | Privacy Policy | `PrivacyPolicyViewController` | +| Logout | Sign Out | `handleLogout()` | + +#### Logout Flow +```swift +private func handleLogout() { + // Show confirmation alert + // On confirm: performLogout() +} + +private func performLogout() { + // Clear user data + // Navigate to login via SceneDelegate + if let sceneDelegate = scene.delegate as? SceneDelegate { + sceneDelegate.switchToLogin() + } +} +``` + +--- + +## 7. Onboarding Flow + +### 7.1 File Structure +``` +Screens/Onboarding/ +├── SplashViewController.swift +├── OnboardingController.swift +├── OnboardingPage.swift +├── LoginViewController.swift +├── SignupViewController.swift +├── ForgotPasswordViewController.swift +├── ModernTextField.swift +└── SocialButton.swift +``` + +### 7.2 Complete Flow + +``` +┌──────────────────────┐ +│ SplashViewController │ +│ │ +│ ┌────────────────┐ │ +│ │ EnVision │ │ +│ │ Logo │ │ +│ │ (animated) │ │ +│ └────────────────┘ │ +│ │ +│ "See it in your │ +│ space, before │ +│ you buy it." │ +└──────────┬───────────┘ + │ goNext() after animation + ▼ +┌─────────────────────────────────┐ +│ OnboardingController │ +│ │ +│ ┌───────────────────────────┐ │ +│ │ Page 1: Scan │ │ +│ │ 🔲 "Scan Your Room" │ │ +│ │ Turn your space into │ │ +│ │ a 3D model using AR. │ │ +│ └───────────────────────────┘ │ +│ │ +│ ● ○ ○ │ +│ │ +│ [Skip] [Continue] │ +└─────────────┬───────────────────┘ + │ (swipe or tap Continue) + ▼ +┌─────────────────────────────────┐ +│ Page 2: Capture │ +│ 📷 "Capture Any Furniture" │ +│ Transform real items into │ +│ 3D models. │ +│ │ +│ ○ ● ○ │ +└─────────────┬───────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Page 3: Visualize │ +│ 🔮 "Visualize with Confidence" │ +│ See how items fit before │ +│ you buy. │ +│ │ +│ ○ ○ ● │ +│ │ +│ [Skip] [Get Started] │ +└─────────────┬───────────────────┘ + │ goToLogin() + ▼ +┌─────────────────────────────────┐ +│ LoginViewController │ +│ │ +│ ┌─────────────────┐ │ +│ │ EnVision │ │ +│ └─────────────────┘ │ +│ │ +│ [Email Field] │ +│ [Password Field] │ +│ │ +│ [Continue Button] │ +│ │ +│ Forgot password? Create Account│ +│ │ +│ [Sign in with Apple] │ +│ [Sign in with Google] │ +└──────────┬──────────────────────┘ + │ + ┌─────┴─────┐ + ▼ ▼ +┌──────────┐ ┌──────────────────┐ +│ Signup │ │ ForgotPassword │ +│VC │ │ VC │ +└────┬─────┘ └──────────────────┘ + │ + ▼ +┌──────────────────────┐ +│ MainTabBarController │ +│ (via SceneDelegate) │ +└──────────────────────┘ +``` + +### 7.3 SplashViewController.swift + +```swift +class SplashViewController: UIViewController { + private let iconView: UIImageView // App logo + private let titleLabel: UILabel // "EnVision" + private let subLabel: UILabel // Tagline + + override func viewDidAppear(_ animated: Bool) { + animateLogo() + } + + private func animateLogo() { + // Spring animation: scale 0.92 → 1.02 → 1.0 + // Duration: 1.2s + // On completion: goNext() + } + + private func goNext() { + // Present OnboardingController with crossDissolve + } +} +``` + +### 7.4 LoginViewController.swift + +#### Key Methods + +| Method | Purpose | +|--------|---------| +| `handleLogin()` | Validate & login via UserManager | +| `goToSignup()` | Push SignupViewController | +| `goToForgotPassword()` | Push ForgotPasswordViewController | +| `showError(_:)` | Display error label | + +#### Login Flow +```swift +@objc private func handleLogin() { + let email = emailField.textField.text ?? "" + let password = passwordField.textField.text ?? "" + + guard !email.isEmpty, !password.isEmpty else { + showError("All fields are required.") + return + } + guard email.isValidEmail else { + showError("Invalid email format.") + return + } + + UserManager.shared.login(email: email, password: password) { result in + switch result { + case .success: + sceneDelegate.switchToMainApp() + case .failure(let error): + self.showError(error.localizedDescription) + } + } +} +``` + +--- + +## 8. Data Models + +### 8.1 RoomMetadata + +```swift +struct RoomMetadata: Codable { + var category: RoomCategory? + var roomType: RoomType? + var createdAt: Date + var dimensions: RoomDimensions? + var tags: [String] + var notes: String? +} + +struct RoomDimensions: Codable { + let width: Double + let height: Double + let length: Double +} + +struct RoomsMetadata: Codable { + var version: String + var rooms: [String: RoomMetadata] // filename -> metadata +} +``` + +### 8.2 FurnitureMetadata + +```swift +struct FurnitureMetadata: Codable { + var category: FurnitureCategory? + var furnitureType: FurnitureType? + var createdAt: Date + var tags: [String] + var notes: String? +} +``` + +### 8.3 UserModel + +```swift +struct UserModel: Codable { + var id: String + var name: String + var email: String + var bio: String? + var profileImagePath: String? + var preferences: UserPreferences? +} + +struct UserPreferences: Codable { + var theme: Int // 0: Light, 1: Dark, 2: System + var notifications: Bool + var haptics: Bool +} +``` + +### 8.4 RoomModel + +```swift +struct RoomModel { + let id: UUID + var name: String + var createdAt: Date + var thumbnail: UIImage? + var sizeDescription: String + var capturedRoom: CapturedRoom // From RoomPlan +} +``` + +--- + +## 9. Extensions & Utilities + +### 9.1 File Structure +``` +Extensions/ +├── Entity+Visit.swift +├── Extensions.swift +├── SaveManager.swift +├── UIColor+Hex.swift +├── UIFont+AppFonts.swift +├── UIViewController+Transition.swift +├── UserManager.swift +└── UserModel.swift +``` + +### 9.2 Entity+Visit.swift + +```swift +extension Entity { + /// Recursively visits all child entities + func visit(_ closure: (Entity) -> Void) { + closure(self) + for child in children { + child.visit(closure) + } + } +} +``` + +### 9.3 Extensions.swift + +```swift +// String validation +extension String { + var isValidEmail: Bool { + // Regex validation + } + + var isStrongPassword: Bool { + // 8+ chars, 1 uppercase, 1 number + } +} + +// UIView helpers +extension UIView { + func applyGradientBackground(colors: [UIColor]) +} +``` + +### 9.4 UIColor+Hex.swift + +```swift +extension UIColor { + convenience init(hex: String) { + // Parse hex string to RGB + } + + func toHex() -> String { + // Convert to hex string + } +} + +struct AppColors { + static let accent = UIColor(hex: "#4A9085") + static let primary = UIColor(hex: "#2C3E50") + static let secondary = UIColor(hex: "#7F8C8D") + static let background = UIColor(hex: "#F5F6FA") + static let error = UIColor(hex: "#E74C3C") + static let success = UIColor(hex: "#27AE60") +} +``` + +### 9.5 UIFont+AppFonts.swift + +```swift +struct AppFonts { + static func regular(_ size: CGFloat) -> UIFont + static func medium(_ size: CGFloat) -> UIFont + static func semibold(_ size: CGFloat) -> UIFont + static func bold(_ size: CGFloat) -> UIFont +} +``` + +### 9.6 SaveManager.swift + +```swift +final class SaveManager { + static let shared = SaveManager() + + enum ModelType: String { + case room = "roomPlan" + case furniture = "furniture" + } + + // Core Methods + func saveModel(from sourceURL: URL, type: ModelType, + customName: String?, completion: @escaping (URL?) -> Void) + func getSavedModels(type: ModelType) -> [URL] + func deleteModel(at url: URL, completion: @escaping (Bool) -> Void) + func getThumbnail(for url: URL, completion: @escaping (UIImage?) -> Void) + func getMetadata(for url: URL) -> ModelMetadata? + func getStorageInfo(type: ModelType) -> (count: Int, totalSize: Int64) +} +``` + +### 9.7 UserManager.swift + +```swift +final class UserManager { + static let shared = UserManager() + + var currentUser: UserModel? + + // Authentication + func login(email: String, password: String, + completion: @escaping (Result) -> Void) + func signup(name: String, email: String, password: String, + completion: @escaping (Result) -> Void) + func logout() + + // Profile + func updateProfile(name: String?, email: String?, bio: String?) + func updatePreferences(_ preferences: UserPreferences) + func saveProfileImage(_ image: UIImage) + func loadProfileImage() -> UIImage? +} +``` + +--- + +## 10. Components + +### 10.1 File Structure +``` +Components/ +├── CustomTextField.swift +├── PrimaryButton.swift +└── PrimaryButton1.swift +``` + +### 10.2 CustomTextField.swift + +```swift +final class CustomTextField: UITextField { + // Styled text field with padding + // Rounded corners, border + // Placeholder styling +} +``` + +### 10.3 PrimaryButton.swift + +```swift +final class PrimaryButton: UIButton { + init(title: String) { + // Green background (#4A9085) + // White text + // Rounded corners + } +} +``` + +### 10.4 PrimaryButton1.swift + +```swift +final class PrimaryButton1: UIButton { + init(title: String) { + // Uses UIButton.Configuration (modern API) + // Filled style + // cornerStyle: .large + } +} +``` + +### 10.5 ModernTextField.swift + +```swift +final class ModernTextField: UIView { + let textField = UITextField() + private let floatingLabel = UILabel() + private let eyeButton = UIButton() + + init(placeholder: String, secure: Bool = false) + + // Features: + // - Floating label animation + // - Show/hide password toggle + // - Focus state styling +} +``` + +### 10.6 SocialButton.swift + +```swift +final class SocialButton: UIButton { + init(title: String, image: UIImage?) { + // Horizontal stack: icon + label + // Light background with border + // Shadow effect + } +} +``` + +--- + +## 11. 3D/AR Features + +### 11.1 RoomPlan Integration + +**RoomPlanScannerViewController.swift** +```swift +final class RoomPlanScannerViewController: UIViewController, + RoomCaptureSessionDelegate { + private let captureSession = RoomCaptureSession() + private lazy var captureView = RoomCaptureView() + private var capturedRoom: CapturedRoom? + + // Delegate methods + func captureSession(_ session: RoomCaptureSession, + didUpdate room: CapturedRoom) + func captureSession(_ session: RoomCaptureSession, + didEndWith room: CapturedRoom, error: Error?) +} +``` + +### 11.2 Object Capture Integration + +**ObjectScanViewController.swift** +```swift +final class ObjectScanViewController: UIViewController { + // Camera + private let session = AVCaptureSession() + private let photoOutput = AVCapturePhotoOutput() + + // Auto-capture timer + private var captureTimer: Timer? + private var images: [URL] = [] + + // Methods + func startCapturing() // Begin auto-capture + func stopCapture() // Finish and process + func capturePhoto() // Single photo capture +} +``` + +**ObjectCapturePreviewController.swift** +```swift +// Uses PhotogrammetrySession to create 3D model +// Processes captured photos +// Exports USDZ file +``` + +### 11.3 3D Visualization + +**RoomVisualizeVC.swift Features:** +- Non-AR 3D view with orbit camera +- Pan gesture: Rotate camera +- Pinch gesture: Zoom in/out +- Ruler tool: Measure distances +- Furniture placement + +**RoomEditVC.swift Features:** +- All visualization features +- Color picker for room elements +- Labels toggle +- Floating action menu + +### 11.4 AR Preview + +Uses `QLPreviewController` for: +- Full AR experience +- Scale, rotate, move models +- Share screenshots +- USDZ native preview + +--- + +## 12. File Structure Reference + +``` +EnVision/ +├── README.md +├── IMPROVEMENTS.md +├── TECHNICAL_DOCUMENTATION.md (this file) +│ +├── Envision/ +│ ├── AppDelegate.swift +│ ├── SceneDelegate.swift +│ ├── MainTabBarController.swift +│ ├── ViewController.swift +│ ├── Info.plist +│ │ +│ ├── 3D_Models/ +│ │ ├── chair.usdz +│ │ ├── hall.usdz +│ │ ├── ios_room.usdz +│ │ ├── ios_room1.usdz +│ │ └── table.usdz +│ │ +│ ├── Assets.xcassets/ +│ │ ├── AppIcon.appiconset/ +│ │ ├── envision.imageset/ +│ │ ├── google_icon.imageset/ +│ │ ├── sofa.viewfinder.symbolset/ +│ │ └── custom.sofafill.viewfinder.symbolset/ +│ │ +│ ├── Base.lproj/ +│ │ └── LaunchScreen.storyboard +│ │ +│ ├── Components/ +│ │ ├── CustomTextField.swift +│ │ ├── PrimaryButton.swift +│ │ └── PrimaryButton1.swift +│ │ +│ ├── Extensions/ +│ │ ├── Entity+Visit.swift +│ │ ├── Extensions.swift +│ │ ├── SaveManager.swift +│ │ ├── UIColor+Hex.swift +│ │ ├── UIFont+AppFonts.swift +│ │ ├── UIViewController+Transition.swift +│ │ ├── UserManager.swift +│ │ └── UserModel.swift +│ │ +│ └── Screens/ +│ ├── MainTabs/ +│ │ ├── furniture/ +│ │ │ ├── FurnitureCategory.swift +│ │ │ ├── FurnitureCell.swift +│ │ │ ├── ScanFurnitureViewController.swift +│ │ │ ├── CreateModel/ +│ │ │ ├── ModelsFromFiles/ +│ │ │ ├── Object Capture/ +│ │ │ └── roomPlanColor/ +│ │ │ +│ │ ├── profile/ +│ │ │ ├── ProfileViewController.swift +│ │ │ ├── ProfileCell.swift +│ │ │ ├── EditProfileViewController.swift +│ │ │ └── SubScreens/ +│ │ │ +│ │ └── Rooms/ +│ │ ├── MyRoomsViewController.swift +│ │ ├── MyRoomsViewController+helpers.swift +│ │ ├── RoomCell.swift +│ │ ├── RoomCategory.swift +│ │ ├── RoomModel.swift +│ │ ├── MetadataManager.swift +│ │ ├── furniture+room/ +│ │ └── RoomPlanScan/ +│ │ +│ └── Onboarding/ +│ ├── SplashViewController.swift +│ ├── OnboardingController.swift +│ ├── OnboardingPage.swift +│ ├── LoginViewController.swift +│ ├── SignupViewController.swift +│ ├── ForgotPasswordViewController.swift +│ ├── ModernTextField.swift +│ └── SocialButton.swift +│ +└── Envision.xcodeproj/ + └── project.pbxproj +``` + +--- + +## Summary + +EnVision is a well-architected iOS app with: + +1. **Clear Separation**: Each feature in its own folder +2. **Singleton Managers**: Centralized data management +3. **Protocol Extensions**: Clean delegate implementations +4. **Modern UI**: Compositional layouts, context menus, SF Symbols +5. **AR/3D Integration**: RoomPlan, Object Capture, RealityKit +6. **Consistent Styling**: Centralized colors and fonts + +--- + +*Last Updated: January 2, 2026* diff --git a/TIPS_TOUR_IMPROVEMENT_PLAN.md b/TIPS_TOUR_IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..3d4ac5e --- /dev/null +++ b/TIPS_TOUR_IMPROVEMENT_PLAN.md @@ -0,0 +1,1048 @@ +# EnVision Tips & Tour System - Complete Improvement Plan +*Priority: CRITICAL | Status: REMOVED (Needs Rebuild)* + +--- + +## Executive Summary + +The Tips & Tour system was **removed from the codebase** on January 21, 2026 due to: +- SwiftUI `TipView` hosting instability in UIKit +- Vertical text artifacts overlaying all screens +- Non-responsive button actions +- Layout conflicts with collection views and navigation bars + +**This document outlines a complete rebuild using pure UIKit** (no SwiftUI, no TipKit), ensuring: +- Native iOS feel +- Reliable touch handling +- No layout corruption +- Progressive onboarding that guides users through app features + +--- + +## Table of Contents +1. [Original Vision](#1-original-vision) +2. [What Went Wrong](#2-what-went-wrong) +3. [New Architecture (Pure UIKit)](#3-new-architecture-pure-uikit) +4. [Implementation Phases](#4-implementation-phases) +5. [Visual Design Specs](#5-visual-design-specs) +6. [Code Structure](#6-code-structure) +7. [Testing Plan](#7-testing-plan) +8. [Timeline & Milestones](#8-timeline--milestones) + +--- + +## 1. Original Vision + +### 1.1 Purpose +A **TipKit-based progressive onboarding system** that guides users from first launch to advanced features using context-aware tips, managed by a central TourManager, with 25 lightweight tips, a resettable tour, and screen-level integration that shows the right tip at the right time without disrupting the app experience. + +### 1.2 Original Tips (25 Total) + +#### Getting Started (2 tips) +1. **Welcome** - "Take a tour to learn how to scan rooms and furniture" +2. **Profile** - "Customize your profile and preferences" + +#### My Rooms (8 tips) +3. **Intro** - "Scan your first room with LiDAR" +4. **Scan Slowly** - "Move your device slowly for best accuracy" +5. **Import** - "Bring in existing USDZ models" +6. **Actions Menu** - "Select multiple rooms to delete/share" +7. **Categories** - "Organize rooms by type" +8. **Search** - "Find rooms quickly by name" +9. **Room Detail** - "View and edit room metadata" +10. **AR Preview** - "Place furniture in your scanned room" + +#### My Furniture (8 tips) +11. **Intro** - "Capture furniture using 360° photogrammetry" +12. **Automatic Capture** - "Walk around object while camera captures" +13. **Photo Count** - "Take 30-50 photos for best quality" +14. **Good Lighting** - "Ensure even lighting and avoid shadows" +15. **360° Coverage** - "Capture all angles of the object" +16. **From Photos** - "Create models from existing photos" +17. **Import USDZ** - "Bring in furniture models from Files" +18. **Categories** - "Organize furniture by type" + +#### Profile (7 tips) +19. **Settings** - "Customize app behavior" +20. **Theme** - "Choose light, dark, or system theme" +21. **Notifications** - "Enable scan reminders" +22. **Tips Library** - "Browse all tips anytime" +23. **Restart Tour** - "Reset the app tour" +24. **Export Data** - "Backup your scans to iCloud" +25. **Support** - "Contact us for help" + +### 1.3 Original Tour Progression +``` +Launch → Welcome Tip (MainTabBarController) + ↓ +My Rooms (0 rooms) → Intro Tip → Scan First Room + ↓ +My Rooms (1 room) → Actions Menu Tip + Categories Tip + ↓ +My Furniture (0 items) → Intro Tip → Capture First Object + ↓ +My Furniture (1 item) → Quality Tips + ↓ +Profile → Settings Tip → Complete Tour +``` + +--- + +## 2. What Went Wrong + +### 2.1 Technical Issues + +#### Issue 1: SwiftUI Hosting in UIKit +```swift +// Old broken approach +let tipView = TipView(WelcomeTip(), arrowEdge: .top) +let host = UIHostingController(rootView: tipView) +view.addSubview(host.view) +``` + +**Problems**: +- SwiftUI `TipView` has unpredictable layout in UIKit container +- Auto-layout constraints conflict with SwiftUI's layout system +- Views don't clean up properly on dismiss +- Touch events intercepted by SwiftUI layer + +#### Issue 2: Global Overlay at Tab Bar Level +```swift +// In MainTabBarController.viewDidAppear +showWelcomeTip() // Added to tabBarController.view +``` + +**Problems**: +- Tip appears on ALL tabs (leaks across screens) +- Can't be removed reliably when switching tabs +- Vertical text artifact (zero-width constraint issue) +- Blocks touches to tab bar and child view controllers + +#### Issue 3: Type Erasure in TipPresenter +```swift +// Invalid Swift code +class TipPresenter: UIHostingController> { + // Error: 'any Tip' cannot be used as generic parameter +} +``` + +**Problems**: +- `any Tip` is an existential type, not a concrete type +- Can't be used as generic parameter for `TipView` +- Causes compile errors or runtime crashes + +#### Issue 4: Layout Conflicts in MyRoomsViewController +```swift +// Multiple tip hosting attempts +private var tipContainerView: UIView! +private let tipPresenter = TipPresenter(...) +// Both coexist, causing layout fights +``` + +**Problems**: +- Two different tip systems in same view controller +- Constraints added/removed unpredictably +- Collection view insets not reset properly +- Tips cover search bar and chip filters + +### 2.2 User-Facing Symptoms +- ❌ Vertical line of text ("Welcome to EnVision..." rotated 90°) on every screen +- ❌ Tips appear in wrong locations (covering nav bar, search bar) +- ❌ Buttons don't respond to taps +- ❌ Tips don't dismiss when tapping "Later" or "Got It" +- ❌ App feels "broken" and unprofessional +- ❌ No way to skip or disable tips + +--- + +## 3. New Architecture (Pure UIKit) + +### 3.1 Core Principles +1. **No SwiftUI** - Pure UIKit programmatic views +2. **No TipKit** - Custom tip management with TourManager +3. **Container-based** - Tips live inside dedicated containers, not overlays +4. **Layout-safe** - No constraint conflicts with existing UI +5. **Touch-safe** - Tips only intercept touches within their bounds +6. **Dismissible** - Always provide "Later" or "Got It" options +7. **Skippable** - Respect user choice to skip tour + +### 3.2 Component Design + +#### TipBubbleView (UIView) +A reusable UIView that displays a tip with: +- **Arrow pointer** (CAShapeLayer) pointing to target element +- **Title** (bold, 16pt) +- **Message** (regular, 14pt, 2-3 lines max) +- **Primary action button** (e.g., "Try It", "Scan Now") +- **Dismiss button** (e.g., "Later", "Got It") +- **Close X** (top-right corner, always available) + +**Layout**: +``` +┌─────────────────────────────┐ +│ × │ (close button) +│ Title (bold) │ +│ Message text wraps to 2-3 │ +│ lines for readability │ +│ ┌─────────┐ ┌──────────┐ │ +│ │ Primary │ │ Later │ │ +│ └─────────┘ └──────────┘ │ +└─────────────────────────────┘ + ▼ (arrow pointer) +``` + +#### TipCoordinator (Manager) +Manages tip lifecycle: +- **Conditions** - Check if tip should show (e.g., "hasSeenWelcomeTip", "roomCount == 0") +- **Show** - Present tip in target view controller's container +- **Dismiss** - Remove tip and mark as seen +- **Skip** - Mark all tips as seen (user choice) +- **Reset** - Clear all seen states (restart tour) + +#### TourManager (Existing, Enhanced) +Stores tour state in UserDefaults: +- `hasCompletedTour: Bool` +- `currentTourStep: Int` (0-25) +- `seenTips: Set` (tip IDs that have been shown) +- `tourSkipped: Bool` (user chose to skip) + +--- + +## 4. Implementation Phases + +### Phase 1: Core Components (Day 1-2) + +#### Task 1.1: Create TipBubbleView +**File**: `Envision/Tips/TipBubbleView.swift` + +```swift +import UIKit + +final class TipBubbleView: UIView { + + enum ArrowEdge { + case top, bottom, left, right + } + + struct Configuration { + let title: String + let message: String + let primaryActionTitle: String + let dismissActionTitle: String + let arrowEdge: ArrowEdge + let arrowOffset: CGFloat // Horizontal/vertical offset from center + } + + // MARK: - Callbacks + var onPrimaryAction: (() -> Void)? + var onDismiss: (() -> Void)? + + // MARK: - UI Elements + private let containerView = UIView() + private let titleLabel = UILabel() + private let messageLabel = UILabel() + private let primaryButton = UIButton(type: .system) + private let dismissButton = UIButton(type: .system) + private let closeButton = UIButton(type: .system) + private let arrowLayer = CAShapeLayer() + + private let config: Configuration + + // MARK: - Init + init(configuration: Configuration) { + self.config = configuration + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + // Container (rounded rect with shadow) + containerView.backgroundColor = .systemBackground + containerView.layer.cornerRadius = 16 + containerView.layer.shadowColor = UIColor.black.cgColor + containerView.layer.shadowOpacity = 0.15 + containerView.layer.shadowOffset = CGSize(width: 0, height: 4) + containerView.layer.shadowRadius = 12 + + // Arrow (triangle pointer) + arrowLayer.fillColor = UIColor.systemBackground.cgColor + layer.addSublayer(arrowLayer) + + // Title + titleLabel.text = config.title + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + titleLabel.numberOfLines = 1 + + // Message + messageLabel.text = config.message + messageLabel.font = .systemFont(ofSize: 14) + messageLabel.textColor = .secondaryLabel + messageLabel.numberOfLines = 3 + + // Primary button + primaryButton.setTitle(config.primaryActionTitle, for: .normal) + primaryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold) + primaryButton.backgroundColor = .systemBlue + primaryButton.setTitleColor(.white, for: .normal) + primaryButton.layer.cornerRadius = 10 + primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside) + + // Dismiss button + dismissButton.setTitle(config.dismissActionTitle, for: .normal) + dismissButton.titleLabel?.font = .systemFont(ofSize: 15) + dismissButton.setTitleColor(.secondaryLabel, for: .normal) + dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + + // Close button + closeButton.setImage(UIImage(systemName: "xmark"), for: .normal) + closeButton.tintColor = .tertiaryLabel + closeButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + + // Layout + [containerView, titleLabel, messageLabel, primaryButton, dismissButton, closeButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + addSubview(containerView) + containerView.addSubview(titleLabel) + containerView.addSubview(messageLabel) + containerView.addSubview(primaryButton) + containerView.addSubview(dismissButton) + containerView.addSubview(closeButton) + + NSLayoutConstraint.activate([ + // Container (main bubble) + containerView.topAnchor.constraint(equalTo: topAnchor, constant: arrowHeight()), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + + // Close button + closeButton.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 12), + closeButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -12), + closeButton.widthAnchor.constraint(equalToConstant: 24), + closeButton.heightAnchor.constraint(equalToConstant: 24), + + // Title + titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8), + + // Message + messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + messageLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + messageLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), + + // Buttons + primaryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 16), + primaryButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + primaryButton.heightAnchor.constraint(equalToConstant: 44), + primaryButton.widthAnchor.constraint(equalToConstant: 120), + primaryButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -16), + + dismissButton.centerYAnchor.constraint(equalTo: primaryButton.centerYAnchor), + dismissButton.leadingAnchor.constraint(equalTo: primaryButton.trailingAnchor, constant: 12), + dismissButton.heightAnchor.constraint(equalToConstant: 44) + ]) + } + + override func layoutSubviews() { + super.layoutSubviews() + drawArrow() + } + + private func arrowHeight() -> CGFloat { + return config.arrowEdge == .top ? 12 : 0 + } + + private func drawArrow() { + let arrowSize: CGFloat = 12 + let path = UIBezierPath() + + switch config.arrowEdge { + case .top: + let centerX = bounds.width / 2 + config.arrowOffset + path.move(to: CGPoint(x: centerX, y: 0)) + path.addLine(to: CGPoint(x: centerX - arrowSize, y: arrowSize)) + path.addLine(to: CGPoint(x: centerX + arrowSize, y: arrowSize)) + path.close() + default: + break // Add other directions if needed + } + + arrowLayer.path = path.cgPath + } + + @objc private func primaryTapped() { + onPrimaryAction?() + } + + @objc private func dismissTapped() { + onDismiss?() + } +} +``` + +#### Task 1.2: Create TipCoordinator +**File**: `Envision/Tips/TipCoordinator.swift` + +```swift +import UIKit + +final class TipCoordinator { + static let shared = TipCoordinator() + + private init() {} + + // MARK: - Public API + + func showTip( + id: String, + configuration: TipBubbleView.Configuration, + in viewController: UIViewController, + containerView: UIView, + onPrimaryAction: @escaping () -> Void + ) { + // Check if already seen + guard !hasSeen(tipID: id), !TourManager.shared.tourSkipped else { return } + + // Create tip bubble + let tipView = TipBubbleView(configuration: configuration) + tipView.translatesAutoresizingMaskIntoConstraints = false + tipView.alpha = 0 + + tipView.onPrimaryAction = { [weak self, weak viewController] in + self?.markAsSeen(tipID: id) + self?.dismissTip(from: containerView) + onPrimaryAction() + } + + tipView.onDismiss = { [weak self] in + self?.markAsSeen(tipID: id) + self?.dismissTip(from: containerView) + } + + // Add to container + containerView.addSubview(tipView) + NSLayoutConstraint.activate([ + tipView.topAnchor.constraint(equalTo: containerView.topAnchor), + tipView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + tipView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16) + ]) + + // Animate in + UIView.animate(withDuration: 0.3, delay: 0.2, options: .curveEaseOut) { + tipView.alpha = 1 + } + } + + func dismissTip(from containerView: UIView) { + guard let tipView = containerView.subviews.first(where: { $0 is TipBubbleView }) else { return } + + UIView.animate(withDuration: 0.2, animations: { + tipView.alpha = 0 + }) { _ in + tipView.removeFromSuperview() + } + } + + // MARK: - State Management + + private func hasSeen(tipID: String) -> Bool { + return TourManager.shared.seenTips.contains(tipID) + } + + private func markAsSeen(tipID: String) { + TourManager.shared.markTipAsSeen(tipID) + } +} +``` + +#### Task 1.3: Enhance TourManager +**File**: `Envision/Managers/TourManager.swift` + +```swift +// Add these properties and methods + +var seenTips: Set { + get { + guard let array = UserDefaults.standard.array(forKey: "seenTips") as? [String] else { + return [] + } + return Set(array) + } + set { + UserDefaults.standard.set(Array(newValue), forKey: "seenTips") + } +} + +var tourSkipped: Bool { + get { UserDefaults.standard.bool(forKey: "tourSkipped") } + set { UserDefaults.standard.set(newValue, forKey: "tourSkipped") } +} + +func markTipAsSeen(_ tipID: String) { + var seen = seenTips + seen.insert(tipID) + seenTips = seen +} + +func skipTour() { + tourSkipped = true +} + +func resetTour() { + hasCompletedTour = false + currentTourStep = 0 + seenTips = [] + tourSkipped = false +} +``` + +--- + +### Phase 2: Tip Definitions (Day 2-3) + +#### Task 2.1: Define Tip Content +**File**: `Envision/Tips/TipDefinitions.swift` + +```swift +import UIKit + +struct TipDefinition { + let id: String + let title: String + let message: String + let primaryActionTitle: String + let dismissActionTitle: String + let arrowEdge: TipBubbleView.ArrowEdge +} + +enum AppTips { + + // MARK: - Welcome + static let welcome = TipDefinition( + id: "welcome", + title: "Welcome to EnVision", + message: "Scan rooms with LiDAR and capture furniture with photogrammetry.", + primaryActionTitle: "Start Tour", + dismissActionTitle: "Skip", + arrowEdge: .top + ) + + // MARK: - My Rooms + static let roomsIntro = TipDefinition( + id: "rooms_intro", + title: "Scan Your First Room", + message: "Tap the camera button to start scanning with LiDAR.", + primaryActionTitle: "Try It", + dismissActionTitle: "Later", + arrowEdge: .top + ) + + static let roomImport = TipDefinition( + id: "room_import", + title: "Import Existing Models", + message: "Already have USDZ files? Import them from Files app.", + primaryActionTitle: "Import", + dismissActionTitle: "Later", + arrowEdge: .top + ) + + static let roomActions = TipDefinition( + id: "room_actions", + title: "Manage Your Rooms", + message: "Select multiple rooms to delete or share at once.", + primaryActionTitle: "Got It", + dismissActionTitle: "Later", + arrowEdge: .top + ) + + static let roomCategories = TipDefinition( + id: "room_categories", + title: "Organize by Category", + message: "Filter rooms by type: Living Room, Bedroom, Kitchen, etc.", + primaryActionTitle: "Got It", + dismissActionTitle: "Later", + arrowEdge: .top + ) + + // MARK: - My Furniture + static let furnitureIntro = TipDefinition( + id: "furniture_intro", + title: "Capture Furniture", + message: "Use 360° photogrammetry to create 3D models of objects.", + primaryActionTitle: "Try It", + dismissActionTitle: "Later", + arrowEdge: .top + ) + + static let furnitureQuality = TipDefinition( + id: "furniture_quality", + title: "Best Results", + message: "Walk slowly around the object. Capture 30-50 photos for high quality.", + primaryActionTitle: "Got It", + dismissActionTitle: "Later", + arrowEdge: .top + ) + + // MARK: - Profile + static let profileSettings = TipDefinition( + id: "profile_settings", + title: "Customize Settings", + message: "Change theme, enable notifications, and manage preferences.", + primaryActionTitle: "Open Settings", + dismissActionTitle: "Later", + arrowEdge: .top + ) + + static let tipsLibrary = TipDefinition( + id: "tips_library", + title: "Browse All Tips", + message: "View all tips and tutorials anytime from here.", + primaryActionTitle: "Got It", + dismissActionTitle: "Later", + arrowEdge: .top + ) +} +``` + +--- + +### Phase 3: Screen Integration (Day 3-5) + +#### Task 3.1: MyRoomsViewController Tips +**File**: `Envision/Screens/MainTabs/Rooms/MyRoomsViewController.swift` + +```swift +// Add these properties +private var tipContainerView: UIView! + +// In viewDidLoad() +setupTipContainer() + +// Add method +private func setupTipContainer() { + tipContainerView = UIView() + tipContainerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tipContainerView) + + NSLayoutConstraint.activate([ + tipContainerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + tipContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tipContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) +} + +// In viewDidAppear() +showContextualTips() + +private func showContextualTips() { + if rooms.isEmpty { + // Show intro tip + TipCoordinator.shared.showTip( + id: AppTips.roomsIntro.id, + configuration: TipBubbleView.Configuration( + title: AppTips.roomsIntro.title, + message: AppTips.roomsIntro.message, + primaryActionTitle: AppTips.roomsIntro.primaryActionTitle, + dismissActionTitle: AppTips.roomsIntro.dismissActionTitle, + arrowEdge: .top, + arrowOffset: 0 + ), + in: self, + containerView: tipContainerView + ) { [weak self] in + // Primary action: trigger scan + self?.scanButtonTapped() + } + } else if rooms.count == 1 { + // Show actions menu tip + TipCoordinator.shared.showTip( + id: AppTips.roomActions.id, + configuration: TipBubbleView.Configuration( + title: AppTips.roomActions.title, + message: AppTips.roomActions.message, + primaryActionTitle: AppTips.roomActions.primaryActionTitle, + dismissActionTitle: AppTips.roomActions.dismissActionTitle, + arrowEdge: .top, + arrowOffset: 0 + ), + in: self, + containerView: tipContainerView + ) { } + } +} +``` + +#### Task 3.2: ScanFurnitureViewController Tips +Similar pattern to MyRoomsViewController. + +#### Task 3.3: ProfileViewController Tips +Similar pattern, show settings tip on first visit. + +--- + +### Phase 4: Welcome Flow (Day 5-6) + +#### Task 4.1: Welcome Tip in SplashViewController +**File**: `Envision/Screens/Onboarding/SplashViewController.swift` + +```swift +// After animation completes in goNext() +private func goNext() { + // ... existing animation code ... + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self = self else { return } + + if TourManager.shared.hasCompletedTour || TourManager.shared.tourSkipped { + // Skip onboarding, go straight to main app (if logged in) + if UserManager.shared.isLoggedIn { + self.showMainApp() + } else { + self.showOnboarding() + } + } else { + // First-time user: show onboarding + self.showOnboarding() + } + } +} +``` + +#### Task 4.2: Welcome Tip in MainTabBarController +**File**: `Envision/MainTabBarController.swift` + +```swift +override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + showWelcomeTipIfNeeded() +} + +private func showWelcomeTipIfNeeded() { + guard !TourManager.shared.hasSeen(tipID: AppTips.welcome.id) else { return } + + let alert = UIAlertController( + title: AppTips.welcome.title, + message: AppTips.welcome.message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Start Tour", style: .default) { _ in + TourManager.shared.startTour() + TourManager.shared.markTipAsSeen(AppTips.welcome.id) + }) + + alert.addAction(UIAlertAction(title: "Skip", style: .cancel) { _ in + TourManager.shared.skipTour() + }) + + present(alert, animated: true) +} +``` + +--- + +### Phase 5: Testing & Polish (Day 6-7) + +#### Task 5.1: Manual Testing +- [ ] Fresh install: welcome tip shows +- [ ] Skip tour: no more tips appear +- [ ] Start tour: tips appear in correct sequence +- [ ] Tip dismissal: "Later" button works +- [ ] Tip action: "Try It" button navigates correctly +- [ ] Restart tour: all tips show again +- [ ] Multiple screens: tips don't leak across tabs +- [ ] Rotation: tips reposition correctly +- [ ] Dark mode: tips use system colors +- [ ] Accessibility: VoiceOver reads tip content + +#### Task 5.2: Edge Cases +- [ ] User logs out mid-tour: tour state preserved +- [ ] User deletes all rooms: intro tip shows again +- [ ] App backgrounds mid-tip: tip still visible on return +- [ ] Rapid tab switching: no multiple tips stacked + +--- + +## 5. Visual Design Specs + +### 5.1 Colors +```swift +// Light Mode +backgroundColor: .systemBackground (white) +titleColor: .label (black) +messageColor: .secondaryLabel (gray) +primaryButtonBg: .systemBlue +primaryButtonText: .white +dismissButtonText: .secondaryLabel +closeButtonTint: .tertiaryLabel +shadowColor: UIColor.black.withAlphaComponent(0.15) + +// Dark Mode (automatic via system colors) +backgroundColor: .systemBackground (dark gray) +titleColor: .label (white) +// ... etc +``` + +### 5.2 Typography +```swift +title: .systemFont(ofSize: 16, weight: .semibold) +message: .systemFont(ofSize: 14, weight: .regular) +primaryButton: .systemFont(ofSize: 15, weight: .semibold) +dismissButton: .systemFont(ofSize: 15, weight: .regular) +``` + +### 5.3 Spacing +```swift +containerPadding: 16pt +cornerRadius: 16pt +buttonHeight: 44pt +buttonCornerRadius: 10pt +arrowHeight: 12pt +shadowRadius: 12pt +shadowOffset: (0, 4) +``` + +### 5.4 Animation +```swift +fadeIn: 0.3s, easeOut, delay 0.2s +fadeOut: 0.2s, easeIn +springAnimation: damping 0.7, velocity 0.5 +``` + +--- + +## 6. Code Structure + +### 6.1 New Files to Create +``` +Envision/Tips/ +├── TipBubbleView.swift (350 lines) +├── TipCoordinator.swift (120 lines) +├── TipDefinitions.swift (200 lines, all 25 tips) +└── README.md (Usage guide) +``` + +### 6.2 Files to Modify +``` +Envision/Managers/ +└── TourManager.swift (Add seenTips, tourSkipped) + +Envision/Screens/Onboarding/ +└── SplashViewController.swift (Add tour check in goNext) + +Envision/MainTabBarController.swift (Add welcome alert) + +Envision/Screens/MainTabs/Rooms/ +└── MyRoomsViewController.swift (Add tipContainerView, showContextualTips) + +Envision/Screens/MainTabs/furniture/ +└── ScanFurnitureViewController.swift (Add tips) + +Envision/Screens/MainTabs/profile/ +└── ProfileViewController.swift (Add tips) +``` + +### 6.3 Files to Keep Unchanged +``` +Envision/Screens/MainTabs/profile/SubScreens/ +└── TipsLibraryViewController.swift (Static tips list, no changes) +``` + +--- + +## 7. Testing Plan + +### 7.1 Unit Tests +```swift +// TourManagerTests.swift +func testMarkTipAsSeen() { + TourManager.shared.resetTour() + TourManager.shared.markTipAsSeen("test_tip") + XCTAssertTrue(TourManager.shared.seenTips.contains("test_tip")) +} + +func testSkipTour() { + TourManager.shared.skipTour() + XCTAssertTrue(TourManager.shared.tourSkipped) +} + +func testResetTour() { + TourManager.shared.markTipAsSeen("test_tip") + TourManager.shared.skipTour() + TourManager.shared.resetTour() + XCTAssertFalse(TourManager.shared.tourSkipped) + XCTAssertEqual(TourManager.shared.seenTips.count, 0) +} +``` + +### 7.2 UI Tests +```swift +// TipsUITests.swift +func testWelcomeTipAppears() { + app.launch() + // Reset tour state first + XCTAssertTrue(app.alerts["Welcome to EnVision"].waitForExistence(timeout: 3)) +} + +func testTipDismissal() { + app.buttons["Later"].tap() + XCTAssertFalse(app.otherElements["TipBubbleView"].exists) +} + +func testSkipTour() { + app.buttons["Skip"].tap() + // Verify no more tips appear +} +``` + +### 7.3 Manual Test Script +```markdown +## Test Case 1: Fresh Install +1. Delete app +2. Clean build & run +3. Verify welcome alert appears after splash +4. Tap "Start Tour" +5. Navigate to My Rooms +6. Verify "Scan Your First Room" tip appears +7. Tap "Try It" +8. Verify tip dismisses and scan starts + +## Test Case 2: Skip Tour +1. Fresh install +2. Tap "Skip" on welcome alert +3. Navigate through all tabs +4. Verify NO tips appear +5. Go to Profile → Restart App Tour +6. Verify tips appear again + +## Test Case 3: Layout Integrity +1. Show tip in My Rooms +2. Verify tip doesn't overlap search bar +3. Verify collection view scrolls normally +4. Tap on room cell (should work, not blocked by tip) +5. Rotate device (if applicable) +6. Verify tip repositions correctly + +## Test Case 4: Dark Mode +1. Enable Dark Mode in Settings +2. Trigger tip +3. Verify colors use system palette +4. Verify text is readable +``` + +--- + +## 8. Timeline & Milestones + +### Week 1: Foundation +**Day 1-2**: Core Components +- ✅ TipBubbleView with arrow pointer +- ✅ TipCoordinator for lifecycle +- ✅ Enhanced TourManager + +**Day 3**: Tip Definitions +- ✅ TipDefinitions.swift with all 25 tips +- ✅ Content review & copywriting + +**Day 4-5**: Screen Integration +- ✅ MyRoomsViewController tips +- ✅ ScanFurnitureViewController tips +- ✅ ProfileViewController tips + +**Day 6**: Welcome Flow +- ✅ SplashViewController tour check +- ✅ MainTabBarController welcome alert + +**Day 7**: Testing & Polish +- ✅ Manual testing all flows +- ✅ Bug fixes +- ✅ Animation tuning + +### Week 2: Validation +**Day 8-9**: Beta Testing +- Internal testing (team) +- Edge case discovery +- Accessibility audit + +**Day 10**: Final Fixes +- Address beta feedback +- Performance optimization +- Code review + +**Day 11**: Documentation +- Update README +- Usage guide for tips +- API documentation + +**Day 12**: Release +- Merge to main +- Deploy to TestFlight +- Monitor crash reports + +--- + +## Success Metrics + +### Technical +- ✅ Zero SwiftUI dependencies +- ✅ Zero TipKit dependencies +- ✅ No layout constraint errors in console +- ✅ Tips dismiss reliably (100% success rate) +- ✅ Buttons respond to first tap (no double-tap needed) +- ✅ No vertical text artifacts +- ✅ Tips don't block critical UI + +### User Experience +- ✅ Tour completion rate > 60% +- ✅ Skip rate < 40% +- ✅ Tip dismissal time < 5 seconds (engaged users read and act) +- ✅ Zero "tips broken" support tickets +- ✅ Positive feedback in App Store reviews mentioning onboarding + +--- + +## Rollback Plan + +If new tips system causes issues: +1. Comment out `showContextualTips()` calls (1 line per screen) +2. Tips disappear, app functions normally +3. Investigate issue offline +4. Re-enable when fixed + +**Critical**: Tips are non-blocking feature. App must work perfectly without them. + +--- + +## Appendix: Comparison Table + +| Aspect | Old (TipKit) | New (UIKit) | +|--------|--------------|-------------| +| Framework | SwiftUI TipView | Pure UIKit | +| Dependencies | TipKit (iOS 17+) | None | +| Layout | SwiftUI auto-layout | UIKit constraints | +| Touch Handling | SwiftUI gestures | UIButton actions | +| Stability | ❌ Unreliable | ✅ Predictable | +| Customization | ⚠️ Limited | ✅ Full control | +| Debugging | ❌ Hard | ✅ Easy | +| Maintenance | ❌ Breaking changes | ✅ Stable | +| Lines of Code | ~500 (with workarounds) | ~700 (clean) | + +--- + +**Document Version**: 1.0 +**Status**: Ready for Implementation +**Priority**: CRITICAL +**Estimated Effort**: 7-12 days +**Risk**: Low (fallback available) + +--- + +*End of Tips & Tour Improvement Plan*