diff --git a/.claude/WORKFLOW.md b/.claude/WORKFLOW.md
index c49ea28..7852973 100644
--- a/.claude/WORKFLOW.md
+++ b/.claude/WORKFLOW.md
@@ -41,11 +41,11 @@ main (production-ready)
│
└─── dev (integration branch)
│
- ├─── feature/ddd-value-objects (task branch)
- ├─── feature/drag-and-drop (task branch)
- ├─── feature/bottomsheet-redesign (task branch)
- ├─── bugfix/bottomsheet-scroll (task branch)
- └─── ci/ios-workflow (task branch)
+ ├─── feature/ARS-5-3d-model-preview (task branch)
+ ├─── bugfix/ARS-6-gallery-crash (task branch)
+ ├─── fix/ARS-7-camera-controls-symmetry (task branch)
+ ├─── feature/ARS-8-short-description (task branch)
+ └─── bugfix/ARS-9-short-description (task branch)
```
### Branch Types
@@ -78,7 +78,7 @@ main (production-ready)
│ Agent creates feature branch from 'dev' │
│ git checkout dev │
│ git pull origin dev │
-│ git checkout -b feature/task-name │
+│ git checkout -b feature/ARS-N-task-name │
└───────────────────────┬─────────────────────────────────────┘
│
▼
@@ -105,7 +105,7 @@ main (production-ready)
│ Agent commits and pushes to remote │
│ git add . │
│ git commit -m "feat: task description" │
-│ git push origin feature/task-name │
+│ git push origin feature/ARS-N-task-name │
└───────────────────────┬─────────────────────────────────────┘
│
▼
@@ -159,18 +159,18 @@ main (production-ready)
### Components
- **type**: Branch type prefix (feature, bugfix, ci, refactor, test)
-- **task-id**: Task ID from SQL database (kebab-case)
+- **task-id**: Task ID from Jira board (ARS-N format, e.g. ARS-5)
- **short-description**: Optional 2-3 word description
### Examples
| Task ID | Branch Name | Agent |
|---------|-------------|-------|
-| `ddd-value-objects` | `refactor/ddd-value-objects` | main-developer-agent |
-| `drag-and-drop` | `feature/drag-and-drop` | android-expert-agent |
-| `bottomsheet-scroll-bug` | `bugfix/bottomsheet-scroll` | bug-fixer-agent |
-| `github-workflow-ios` | `ci/ios-workflow` | ios-expert-agent |
-| `prevent-accidental-tap` | `feature/prevent-accidental-tap` | main-developer-agent |
+| `ARS-5` | `feature/ARS-5-3d-model-preview` | android-expert-agent |
+| `ARS-6` | `bugfix/ARS-6-gallery-crash` | bug-fixer-agent |
+| `ARS-7` | `fix/ARS-7-camera-controls-symmetry` | main-developer-agent |
+| `ARS-8` | `feature/ARS-8-short-description` | main-developer-agent |
+| `ARS-9` | `bugfix/ARS-9-short-description` | bug-fixer-agent |
### Branch Type Selection Guide
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index c0aaf0f..eb623e9 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
+ alias(libs.plugins.kover)
}
kotlin {
@@ -23,6 +24,8 @@ kotlin {
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
+ // Required for ModelPreviewThumbnail.ios.kt: platform.SceneKit.*
+ linkerOpts("-framework", "SceneKit")
}
}
@@ -35,6 +38,7 @@ kotlin {
implementation(libs.sceneview)
implementation(libs.arcore)
implementation(libs.gson)
+ implementation(libs.koin.android)
}
commonMain.dependencies {
implementation(compose.runtime)
@@ -48,6 +52,8 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.koin.core)
+ implementation(libs.koin.compose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
@@ -88,3 +94,42 @@ dependencies {
debugImplementation(libs.compose.uiTooling)
}
+koverReport {
+ defaults {
+ html {
+ onCheck = true
+ }
+ xml {
+ onCheck = true
+ }
+ }
+
+ filters {
+ excludes {
+ // Exclude generated code
+ classes("*_Factory", "*_HiltModules*", "Hilt_*", "*BuildConfig")
+ // Exclude Android framework classes
+ packages("*.di", "*.ui.theme")
+ // Exclude Compose generated
+ annotatedBy("androidx.compose.runtime.Composable")
+ }
+ }
+
+ verify {
+ rule {
+ isEnabled = true
+ // Domain layer should have high coverage
+ filters {
+ includes {
+ packages("com.trendhive.arsample.domain.*")
+ packages("com.trendhive.arsample.application.*")
+ }
+ }
+ bound {
+ minValue = 80
+ metric = kotlinx.kover.gradle.plugin.dsl.MetricType.LINE
+ }
+ }
+ }
+}
+
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index d11f843..f0cf202 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -5,6 +5,11 @@
+
+
+
+
+
Unit)? = null,
onDragStart: ((objectId: String) -> Unit)? = null,
onDragMove: ((objectId: String, screenX: Float, screenY: Float) -> Unit)? = null,
- onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)? = null
+ onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)? = null,
+ captureRequest: Boolean = false,
+ onCaptureComplete: ((ByteArray?) -> Unit)? = null,
+ // Video recording callbacks - set by MediaRepository to enable recording
+ onRecordingCallbacksReady: ((onStart: (String) -> Boolean, onStop: () -> Boolean) -> Unit)? = null,
+ onRecordingCallbacksClear: (() -> Unit)? = null
) {
// CRITICAL FIX: Use rememberUpdatedState to ensure callbacks always reference latest values
// This prevents AndroidView factory closure from capturing stale lambda references
@@ -81,10 +87,25 @@ fun ARView(
val currentOnDragMove by rememberUpdatedState(onDragMove)
val currentOnDragEnd by rememberUpdatedState(onDragEnd)
val currentModelPath by rememberUpdatedState(modelPathToLoad)
+ val currentOnCaptureComplete by rememberUpdatedState(onCaptureComplete)
+ val currentOnRecordingCallbacksReady by rememberUpdatedState(onRecordingCallbacksReady)
+ val currentOnRecordingCallbacksClear by rememberUpdatedState(onRecordingCallbacksClear)
val coroutineScope = rememberCoroutineScope()
+ val context = androidx.compose.ui.platform.LocalContext.current
+
+ // Get MediaRepository from Koin for direct recording callback registration
+ val koin = org.koin.compose.LocalKoinApplication.current
+ val mediaRepository = remember(koin) {
+ runCatching {
+ koin.get()
+ }.getOrNull()
+ }
var arSceneView by remember { mutableStateOf(null) }
+
+ // Video recorder instance
+ val videoRecorder = remember { VideoRecorder(context) }
val currentNodes = remember { mutableMapOf() }
// Long-press placement state
@@ -108,6 +129,8 @@ fun ARView(
var dragTouchDownTime by remember { mutableStateOf(0L) }
var dragTouchDownPosition by remember { mutableStateOf?>(null) }
var dragStartNodePosition by remember { mutableStateOf(null) }
+ var dragLastScreenX by remember { mutableStateOf(null) }
+ var dragLastScreenY by remember { mutableStateOf(null) }
// Helper function to reset drag state
fun resetDragState() {
@@ -116,6 +139,8 @@ fun ARView(
dragStartNodePosition = null
dragTouchDownPosition = null
dragTouchDownTime = 0L
+ dragLastScreenX = null
+ dragLastScreenY = null
}
// Helper function to restore dragged node's original state
@@ -205,19 +230,19 @@ fun ARView(
Log.w(TAG, "AR session is not initialized")
return null
}
-
+
val frame = sceneView.frame
if (frame == null) {
Log.w(TAG, "AR frame is not available")
return null
}
-
+
val camera = frame.camera
if (camera.trackingState != TrackingState.TRACKING) {
Log.w(TAG, "AR camera not tracking, state: ${camera.trackingState}")
return null
}
-
+
frame
} catch (e: Exception) {
Log.e(TAG, "Failed to get AR frame: ${e.message}", e)
@@ -225,6 +250,19 @@ fun ARView(
}
}
+ /**
+ * Returns any available AR frame, regardless of tracking state.
+ * Used for node hit-testing which only needs camera matrices, not plane tracking.
+ */
+ fun getAnyARFrame(sceneView: ARSceneView): Frame? {
+ return try {
+ if (sceneView.session == null) return null
+ sceneView.frame
+ } catch (e: Exception) {
+ null
+ }
+ }
+
fun cancelHoldFeedback() {
holdJob?.cancel()
holdJob = null
@@ -242,6 +280,7 @@ fun ARView(
fun worldToScreen(view: ARSceneView, frame: Frame, worldX: Float, worldY: Float, worldZ: Float): Pair? {
return try {
val camera = frame.camera
+ // Require full TRACKING state — projection matrices are only accurate when tracking
if (camera.trackingState != TrackingState.TRACKING) {
return null
}
@@ -296,7 +335,8 @@ fun ARView(
*/
fun hitTestNode(view: ARSceneView, x: Float, y: Float): String? {
return try {
- val frame = getARFrameSafely(view) ?: return null
+ // Use any available frame — node selection doesn't need strict tracking
+ val frame = getAnyARFrame(view) ?: return null
// Screen-space hit detection: project each node to screen and check 2D distance
// This works regardless of where on the model the user touches
@@ -388,6 +428,14 @@ fun ARView(
}
}
+ // Guard: if the LaunchedEffect was cancelled while loading (e.g. user
+ // navigated away and ARSceneView was destroyed), skip addChildNode to
+ // prevent NullPointerException inside SceneView's CameraComponent.
+ if (!isActive || arSceneView == null) {
+ Log.d(TAG, "Skipping addChildNode — view disposed during model load")
+ return@LaunchedEffect
+ }
+
if (modelInstance != null) {
val placedObjectId = obj.objectId
val modelNode = ModelNode(modelInstance).apply {
@@ -449,7 +497,110 @@ fun ARView(
DisposableEffect(Unit) {
onDispose {
- arSceneView?.destroy()
+ // Do NOT call arSceneView?.destroy() here.
+ // SceneView's onDetachedFromWindow() already calls destroy() internally.
+ // Explicit destroy here causes double-destroy → NPE in CameraNode.
+ }
+ }
+
+ // Setup video recording callbacks when ARSceneView is ready
+ DisposableEffect(arSceneView, mediaRepository) {
+ val view = arSceneView
+ if (view != null) {
+ // Set ARSceneView in video recorder
+ videoRecorder.setARSceneView(view)
+
+ // Register recording callbacks directly with MediaRepository
+ val repo = mediaRepository
+ if (repo != null && repo is com.trendhive.arsample.infrastructure.persistence.local.MediaRepositoryImpl) {
+ Log.d(TAG, "Registering video recording callbacks with MediaRepository")
+ repo.setRecordingCallbacks(
+ onStart = { outputPath ->
+ Log.d(TAG, "Starting video recording to: $outputPath")
+ videoRecorder.startRecording(outputPath)
+ },
+ onStop = {
+ Log.d(TAG, "Stopping video recording")
+ videoRecorder.stopRecording()
+ }
+ )
+ } else {
+ // Fallback to callback-based approach if provided
+ currentOnRecordingCallbacksReady?.invoke(
+ { outputPath ->
+ Log.d(TAG, "Starting video recording to: $outputPath")
+ videoRecorder.startRecording(outputPath)
+ },
+ {
+ Log.d(TAG, "Stopping video recording")
+ videoRecorder.stopRecording()
+ }
+ )
+ }
+ }
+
+ onDispose {
+ try {
+ // Stop any ongoing recording
+ if (videoRecorder.isRecording()) {
+ videoRecorder.stopRecording()
+ }
+ // Clear recording callbacks
+ val repo = mediaRepository
+ if (repo != null && repo is com.trendhive.arsample.infrastructure.persistence.local.MediaRepositoryImpl) {
+ repo.clearRecordingCallbacks()
+ } else {
+ currentOnRecordingCallbacksClear?.invoke()
+ }
+ videoRecorder.setARSceneView(null)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error in video recorder dispose", e)
+ }
+ }
+ }
+
+ // Handle capture request
+ LaunchedEffect(captureRequest) {
+ val callback = currentOnCaptureComplete
+ if (captureRequest && callback != null) {
+ val view = arSceneView
+ if (view == null) {
+ callback(null)
+ return@LaunchedEffect
+ }
+
+ try {
+ // Create bitmap from ARSceneView using PixelCopy
+ val bitmap = android.graphics.Bitmap.createBitmap(
+ view.width,
+ view.height,
+ android.graphics.Bitmap.Config.ARGB_8888
+ )
+
+ // Use PixelCopy for hardware-accelerated capture
+ android.view.PixelCopy.request(
+ view,
+ bitmap,
+ { result ->
+ if (result == android.view.PixelCopy.SUCCESS) {
+ // Convert bitmap to JPEG byte array
+ val stream = java.io.ByteArrayOutputStream()
+ bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, stream)
+ val byteArray = stream.toByteArray()
+ Log.d(TAG, "Captured photo: ${byteArray.size} bytes, ${bitmap.width}x${bitmap.height}")
+ callback(byteArray)
+ } else {
+ Log.e(TAG, "PixelCopy failed with result: $result")
+ callback(null)
+ }
+ bitmap.recycle()
+ },
+ android.os.Handler(android.os.Looper.getMainLooper())
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Capture failed: ${e.message}", e)
+ callback(null)
+ }
}
}
@@ -470,6 +621,30 @@ fun ARView(
}
}
+ // Animation update: drive GLB/glTF animations every frame
+ onFrame = { _ ->
+ // Guard: skip if view is no longer attached (partially destroyed)
+ if (isAttachedToWindow) {
+ val elapsedSeconds = System.nanoTime() / 1_000_000_000.0
+ for ((_, node) in currentNodes) {
+ try {
+ val animator = node.modelInstance.animator
+ if (animator.animationCount > 0) {
+ repeat(animator.animationCount) { i ->
+ val duration = animator.getAnimationDuration(i)
+ if (duration > 0f) {
+ animator.applyAnimation(i, (elapsedSeconds % duration).toFloat())
+ }
+ }
+ animator.updateBoneMatrices()
+ }
+ } catch (_: Exception) {
+ // Ignore per-frame animation errors
+ }
+ }
+ }
+ }
+
scaleGestureDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
selectedNodeId?.let { nodeId ->
@@ -622,6 +797,8 @@ fun ARView(
// Check if should start dragging (either moved enough or held long enough)
if (!isDragging && (distance > DRAG_SLOP_PX || elapsed > DRAG_LONG_PRESS_MS)) {
isDragging = true
+ dragLastScreenX = e.x // Initialize last position for delta calculation
+ dragLastScreenY = e.y
Log.d(TAG, "Drag STARTED for object $nodeId (distance=$distance, elapsed=${elapsed}ms)")
// Visual feedback: scale up
@@ -641,16 +818,70 @@ fun ARView(
val hitResults = f.hitTest(e.x, e.y)
val validHits = filterHitResults(hitResults)
- if (validHits.isNotEmpty()) {
- val bestHit = validHits.first()
- val pose = bestHit.hitPose
-
- // Update node position directly
- currentNodes[nodeId]?.let { node ->
+ currentNodes[nodeId]?.let { node ->
+ if (validHits.isNotEmpty()) {
+ val bestHit = validHits.first()
+ val pose = bestHit.hitPose
node.position = Position(pose.tx(), pose.ty(), pose.tz())
+ Log.d(TAG, "Drag MOVE: updated position to (${pose.tx()}, ${pose.ty()}, ${pose.tz()})")
+ } else {
+ // No valid plane hit - use screen delta movement
+ // Project finger movement to world XZ plane (horizontal)
+ val cam = f.camera
+ if (cam.trackingState == TrackingState.TRACKING) {
+ val camPose = cam.pose
+ val oldPos = node.position
+
+ // Calculate distance from camera to object (for scaling)
+ val objDx = oldPos.x - camPose.tx()
+ val objDz = oldPos.z - camPose.tz()
+ val horizontalDist = kotlin.math.sqrt(objDx*objDx + objDz*objDz)
+
+ // Get screen delta from last position (not absolute)
+ val startPos = dragTouchDownPosition
+ val lastX = dragLastScreenX ?: startPos?.first ?: e.x
+ val lastY = dragLastScreenY ?: startPos?.second ?: e.y
+ val screenDeltaX = e.x - lastX
+ val screenDeltaY = e.y - lastY
+
+ // Store current position for next frame
+ dragLastScreenX = e.x
+ dragLastScreenY = e.y
+
+ // Convert screen pixels to world units
+ // Scale factor: pixels to meters (adjust based on distance)
+ val screenWidth = this.width.toFloat()
+ val pixelToWorld = (horizontalDist * 0.002f).coerceIn(0.001f, 0.01f)
+
+ // Get camera's right and forward vectors (horizontal only)
+ val rightVec = camPose.getXAxis()
+ val forwardVec = camPose.getZAxis()
+
+ // Project camera forward to XZ plane (ignore Y component for horizontal movement)
+ val forwardXZ = floatArrayOf(-forwardVec[0], 0f, -forwardVec[2])
+ val forwardLen = kotlin.math.sqrt(forwardXZ[0]*forwardXZ[0] + forwardXZ[2]*forwardXZ[2])
+ if (forwardLen > 0.001f) {
+ forwardXZ[0] /= forwardLen
+ forwardXZ[2] /= forwardLen
+ }
+
+ // Calculate world movement from screen delta
+ // Screen X -> world right direction
+ // Screen Y -> world forward direction (into screen)
+ val worldDeltaX = rightVec[0] * screenDeltaX * pixelToWorld +
+ forwardXZ[0] * screenDeltaY * pixelToWorld
+ val worldDeltaZ = rightVec[2] * screenDeltaX * pixelToWorld +
+ forwardXZ[2] * screenDeltaY * pixelToWorld
+
+ // Apply delta to current position (keep Y height unchanged)
+ val newX = oldPos.x + worldDeltaX
+ val newY = oldPos.y // Keep Y (height) the same
+ val newZ = oldPos.z + worldDeltaZ
+
+ node.position = Position(newX, newY, newZ)
+ Log.d(TAG, "Drag MOVE (fallback): delta=(${screenDeltaX}, ${screenDeltaY}) -> world=($newX, $newY, $newZ)")
+ }
}
-
- Log.d(TAG, "Drag MOVE: updated position to (${pose.tx()}, ${pose.ty()}, ${pose.tz()})")
}
} catch (ex: Exception) {
Log.e(TAG, "Hit test during drag failed: ${ex.message}", ex)
diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/PlatformARView.android.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/PlatformARView.android.kt
index 637ef6a..4df3d58 100644
--- a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/PlatformARView.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/PlatformARView.android.kt
@@ -36,7 +36,11 @@ actual fun PlatformARView(
onObjectPositionChanged: ((placedObjectId: String, x: Float, y: Float, z: Float) -> Unit)?,
onDragStart: ((objectId: String) -> Unit)?,
onDragMove: ((objectId: String, screenX: Float, screenY: Float) -> Unit)?,
- onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)?
+ onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)?,
+ captureRequest: Boolean,
+ onCaptureComplete: ((ByteArray?) -> Unit)?,
+ onRecordingCallbacksReady: ((onStart: (String) -> Boolean, onStop: () -> Boolean) -> Unit)?,
+ onRecordingCallbacksClear: (() -> Unit)?
) {
val context = LocalContext.current
val activity = context as? Activity
@@ -119,7 +123,11 @@ actual fun PlatformARView(
onObjectPositionChanged = onObjectPositionChanged,
onDragStart = onDragStart,
onDragMove = onDragMove,
- onDragEnd = onDragEnd
+ onDragEnd = onDragEnd,
+ captureRequest = captureRequest,
+ onCaptureComplete = onCaptureComplete,
+ onRecordingCallbacksReady = onRecordingCallbacksReady,
+ onRecordingCallbacksClear = onRecordingCallbacksClear
)
}
else -> {
diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/VideoRecorder.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/VideoRecorder.kt
new file mode 100644
index 0000000..c4c9969
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/VideoRecorder.kt
@@ -0,0 +1,458 @@
+package com.trendhive.arsample.ar
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.media.MediaCodec
+import android.media.MediaCodecInfo
+import android.media.MediaFormat
+import android.media.MediaMuxer
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.PixelCopy
+import android.view.Surface
+import io.github.sceneview.ar.ARSceneView
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Helper class for recording video from ARSceneView using MediaCodec and PixelCopy.
+ *
+ * Uses Surface-based encoding approach:
+ * 1. Create MediaCodec encoder with input Surface
+ * 2. Capture frames from ARSceneView using PixelCopy
+ * 3. Draw captured frames to encoder's input Surface
+ * 4. Write encoded data to file using MediaMuxer
+ *
+ * This approach is compatible with ARSceneView and provides good performance.
+ */
+class VideoRecorder(
+ private val context: Context
+) {
+ companion object {
+ private const val TAG = "VideoRecorder"
+
+ // Default video settings
+ private const val DEFAULT_VIDEO_WIDTH = 1280
+ private const val DEFAULT_VIDEO_HEIGHT = 720
+ private const val DEFAULT_VIDEO_BIT_RATE = 8_000_000 // 8 Mbps
+ private const val DEFAULT_VIDEO_FRAME_RATE = 30
+ private const val MIME_TYPE = "video/avc" // H.264
+ private const val I_FRAME_INTERVAL = 1 // I-frame every 1 second
+ }
+
+ private var mediaCodec: MediaCodec? = null
+ private var mediaMuxer: MediaMuxer? = null
+ private var inputSurface: Surface? = null
+ private var trackIndex: Int = -1
+ private var muxerStarted = false
+ private val isRecording = AtomicBoolean(false)
+ private var currentOutputPath: String? = null
+ private var arSceneView: ARSceneView? = null
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ // Video dimensions - set when recording starts
+ private var videoWidth = DEFAULT_VIDEO_WIDTH
+ private var videoHeight = DEFAULT_VIDEO_HEIGHT
+
+ /**
+ * Set the ARSceneView to record from.
+ */
+ fun setARSceneView(view: ARSceneView?) {
+ arSceneView = view
+ }
+
+ /**
+ * Start video recording to the specified output path.
+ * This method is thread-safe and can be called from any thread.
+ * @param outputPath The full path where the video file will be saved
+ * @return true if recording started successfully, false otherwise
+ */
+ fun startRecording(outputPath: String): Boolean {
+ if (isRecording.get()) {
+ Log.w(TAG, "Already recording")
+ return false
+ }
+
+ val view = arSceneView
+ if (view == null) {
+ Log.e(TAG, "ARSceneView not set")
+ return false
+ }
+
+ // Use a latch to wait for main thread operations if we're not on main thread
+ val result = AtomicBoolean(false)
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ // Already on main thread
+ result.set(startRecordingInternal(outputPath, view))
+ } else {
+ // Run on main thread and wait for result
+ val latch = CountDownLatch(1)
+ mainHandler.post {
+ try {
+ result.set(startRecordingInternal(outputPath, view))
+ } finally {
+ latch.countDown()
+ }
+ }
+ try {
+ // Wait up to 5 seconds for recording to start
+ if (!latch.await(5, TimeUnit.SECONDS)) {
+ Log.e(TAG, "Timeout waiting for recording to start")
+ return false
+ }
+ } catch (e: InterruptedException) {
+ Log.e(TAG, "Interrupted while starting recording", e)
+ return false
+ }
+ }
+
+ return result.get()
+ }
+
+ private fun startRecordingInternal(outputPath: String, view: ARSceneView): Boolean {
+ return try {
+ // Ensure parent directory exists
+ File(outputPath).parentFile?.mkdirs()
+
+ // Get view dimensions for recording, ensuring even numbers (required by H.264)
+ videoWidth = ((if (view.width > 0) view.width else DEFAULT_VIDEO_WIDTH) / 2) * 2
+ videoHeight = ((if (view.height > 0) view.height else DEFAULT_VIDEO_HEIGHT) / 2) * 2
+
+ // Cap dimensions to reasonable limits to avoid memory issues
+ if (videoWidth > 1920) {
+ val scale = 1920f / videoWidth
+ videoWidth = 1920
+ videoHeight = ((videoHeight * scale).toInt() / 2) * 2
+ }
+ if (videoHeight > 1080) {
+ val scale = 1080f / videoHeight
+ videoHeight = 1080
+ videoWidth = ((videoWidth * scale).toInt() / 2) * 2
+ }
+
+ Log.d(TAG, "Starting recording with dimensions: ${videoWidth}x${videoHeight}")
+
+ // Create MediaCodec encoder
+ val format = MediaFormat.createVideoFormat(MIME_TYPE, videoWidth, videoHeight).apply {
+ setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
+ setInteger(MediaFormat.KEY_BIT_RATE, DEFAULT_VIDEO_BIT_RATE)
+ setInteger(MediaFormat.KEY_FRAME_RATE, DEFAULT_VIDEO_FRAME_RATE)
+ setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
+ }
+
+ mediaCodec = MediaCodec.createEncoderByType(MIME_TYPE).apply {
+ configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
+ // Get the input surface before starting
+ inputSurface = createInputSurface()
+ start()
+ }
+
+ if (inputSurface == null || !inputSurface!!.isValid) {
+ Log.e(TAG, "Failed to create valid input surface")
+ releaseRecorder()
+ return false
+ }
+
+ // Create MediaMuxer
+ mediaMuxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
+ trackIndex = -1
+ muxerStarted = false
+
+ isRecording.set(true)
+ currentOutputPath = outputPath
+
+ // Start frame capture
+ startFrameCapture(view)
+
+ Log.d(TAG, "Started recording to: $outputPath")
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start recording", e)
+ releaseRecorder()
+ false
+ }
+ }
+
+ /**
+ * Stop video recording.
+ * This method is thread-safe and can be called from any thread.
+ * @return true if recording stopped successfully, false otherwise
+ */
+ fun stopRecording(): Boolean {
+ if (!isRecording.get()) {
+ Log.w(TAG, "Not recording")
+ return false
+ }
+
+ // Mark recording as stopped first to stop frame capture
+ isRecording.set(false)
+
+ // Use a latch to wait for main thread operations if we're not on main thread
+ val result = AtomicBoolean(false)
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ result.set(stopRecordingInternal())
+ } else {
+ val latch = CountDownLatch(1)
+ mainHandler.post {
+ try {
+ result.set(stopRecordingInternal())
+ } finally {
+ latch.countDown()
+ }
+ }
+ try {
+ if (!latch.await(5, TimeUnit.SECONDS)) {
+ Log.e(TAG, "Timeout waiting for recording to stop")
+ return false
+ }
+ } catch (e: InterruptedException) {
+ Log.e(TAG, "Interrupted while stopping recording", e)
+ return false
+ }
+ }
+
+ return result.get()
+ }
+
+ private fun stopRecordingInternal(): Boolean {
+ return try {
+ // Stop frame capture first
+ stopFrameCapture()
+
+ // Signal end of stream to encoder via surface
+ try {
+ mediaCodec?.signalEndOfInputStream()
+ } catch (e: Exception) {
+ Log.w(TAG, "Error signaling end of input stream", e)
+ }
+
+ // Drain remaining encoded data
+ drainEncoder(true)
+
+ // Stop muxer
+ if (muxerStarted) {
+ try {
+ mediaMuxer?.stop()
+ } catch (e: Exception) {
+ Log.w(TAG, "Error stopping muxer", e)
+ }
+ }
+
+ val path = currentOutputPath
+ currentOutputPath = null
+
+ Log.d(TAG, "Stopped recording: $path")
+
+ releaseRecorder()
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping recording", e)
+ releaseRecorder()
+ false
+ }
+ }
+
+ /**
+ * Check if currently recording.
+ */
+ fun isRecording(): Boolean = isRecording.get()
+
+ /**
+ * Release all resources.
+ */
+ fun release() {
+ if (isRecording.get()) {
+ stopRecording()
+ }
+ releaseRecorder()
+ arSceneView = null
+ }
+
+ private fun releaseRecorder() {
+ try {
+ inputSurface?.release()
+ inputSurface = null
+
+ mediaCodec?.stop()
+ mediaCodec?.release()
+ mediaCodec = null
+
+ mediaMuxer?.release()
+ mediaMuxer = null
+
+ trackIndex = -1
+ muxerStarted = false
+ } catch (e: Exception) {
+ Log.e(TAG, "Error releasing recorder", e)
+ }
+ }
+
+ // Frame capture thread for recording
+ private var captureThread: Thread? = null
+ @Volatile private var captureStopped = false
+
+ private fun startFrameCapture(view: ARSceneView) {
+ captureStopped = false
+
+ captureThread = Thread {
+ val frameIntervalMs = 1000L / DEFAULT_VIDEO_FRAME_RATE
+
+ while (!captureStopped && isRecording.get()) {
+ try {
+ // Capture frame and draw to encoder surface
+ captureAndDrawFrame(view)
+
+ // Drain encoded data to muxer
+ drainEncoder(false)
+
+ Thread.sleep(frameIntervalMs)
+ } catch (e: InterruptedException) {
+ break
+ } catch (e: Exception) {
+ Log.e(TAG, "Frame capture error", e)
+ }
+ }
+ Log.d(TAG, "Frame capture thread stopped")
+ }.apply {
+ name = "VideoRecorder-FrameCapture"
+ start()
+ }
+ }
+
+ private fun stopFrameCapture() {
+ captureStopped = true
+ captureThread?.interrupt()
+ try {
+ captureThread?.join(2000)
+ } catch (e: InterruptedException) {
+ // Ignore
+ }
+ captureThread = null
+ }
+
+ private fun captureAndDrawFrame(view: ARSceneView) {
+ val surface = inputSurface ?: return
+ if (!surface.isValid) return
+
+ try {
+ // Create bitmap for capture (use view's actual dimensions for PixelCopy)
+ val viewWidth = view.width.coerceAtLeast(1)
+ val viewHeight = view.height.coerceAtLeast(1)
+
+ val bitmap = Bitmap.createBitmap(
+ viewWidth,
+ viewHeight,
+ Bitmap.Config.ARGB_8888
+ )
+
+ val latch = CountDownLatch(1)
+ var copySuccess = false
+
+ mainHandler.post {
+ try {
+ PixelCopy.request(
+ view,
+ bitmap,
+ { result ->
+ copySuccess = result == PixelCopy.SUCCESS
+ latch.countDown()
+ },
+ mainHandler
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "PixelCopy request failed", e)
+ latch.countDown()
+ }
+ }
+
+ // Wait for PixelCopy with timeout
+ if (!latch.await(100, TimeUnit.MILLISECONDS) || !copySuccess) {
+ bitmap.recycle()
+ return
+ }
+
+ // Draw bitmap to encoder's input surface
+ val canvas: Canvas? = surface.lockCanvas(null)
+ if (canvas != null) {
+ try {
+ // Scale bitmap to match video dimensions if needed
+ if (viewWidth != videoWidth || viewHeight != videoHeight) {
+ val scaleX = videoWidth.toFloat() / viewWidth
+ val scaleY = videoHeight.toFloat() / viewHeight
+ val matrix = Matrix().apply {
+ setScale(scaleX, scaleY)
+ }
+ canvas.drawBitmap(bitmap, matrix, null)
+ } else {
+ canvas.drawBitmap(bitmap, 0f, 0f, null)
+ }
+ } finally {
+ surface.unlockCanvasAndPost(canvas)
+ }
+ }
+
+ bitmap.recycle()
+ } catch (e: Exception) {
+ // Ignore frame capture errors - they're common during transitions
+ }
+ }
+
+ private fun drainEncoder(endOfStream: Boolean) {
+ val codec = mediaCodec ?: return
+ val muxer = mediaMuxer ?: return
+
+ val bufferInfo = MediaCodec.BufferInfo()
+ val timeoutUs = if (endOfStream) 10000L else 0L
+
+ while (true) {
+ val outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, timeoutUs)
+
+ when {
+ outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
+ if (!endOfStream) break
+ // Keep trying when ending stream, but with a limit
+ }
+ outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
+ if (muxerStarted) {
+ Log.w(TAG, "Format changed after muxer started")
+ } else {
+ val newFormat = codec.outputFormat
+ Log.d(TAG, "Encoder output format changed: $newFormat")
+ trackIndex = muxer.addTrack(newFormat)
+ muxer.start()
+ muxerStarted = true
+ }
+ }
+ outputBufferIndex >= 0 -> {
+ val outputBuffer = codec.getOutputBuffer(outputBufferIndex)
+
+ if (outputBuffer != null && muxerStarted) {
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
+ bufferInfo.size = 0
+ }
+
+ if (bufferInfo.size > 0) {
+ outputBuffer.position(bufferInfo.offset)
+ outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
+ muxer.writeSampleData(trackIndex, outputBuffer, bufferInfo)
+ }
+ }
+
+ codec.releaseOutputBuffer(outputBufferIndex, false)
+
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ break
+ }
+ }
+ else -> break
+ }
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/di/PlatformModule.android.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/di/PlatformModule.android.kt
new file mode 100644
index 0000000..ed691d4
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/di/PlatformModule.android.kt
@@ -0,0 +1,27 @@
+package com.trendhive.arsample.di
+
+import com.trendhive.arsample.domain.repository.MediaRepository
+import com.trendhive.arsample.infrastructure.persistence.local.ARObjectLocalDataSource
+import com.trendhive.arsample.infrastructure.persistence.local.ARSceneDataStore
+import com.trendhive.arsample.infrastructure.persistence.local.ModelFileStorage
+import com.trendhive.arsample.infrastructure.persistence.local.ARObjectLocalDataSourceImpl
+import com.trendhive.arsample.infrastructure.persistence.local.ARSceneDataStoreImpl
+import com.trendhive.arsample.infrastructure.persistence.local.ModelFileStorageImpl
+import com.trendhive.arsample.infrastructure.persistence.local.MediaRepositoryImpl
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+actual fun platformDataSourceModule() = module {
+ single {
+ ARObjectLocalDataSourceImpl(androidContext().filesDir)
+ }
+ single {
+ ARSceneDataStoreImpl(androidContext().filesDir)
+ }
+ single {
+ ModelFileStorageImpl(androidContext(), androidContext().filesDir)
+ }
+ single {
+ MediaRepositoryImpl(androidContext())
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/infrastructure/persistence/local/MediaRepositoryImpl.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/infrastructure/persistence/local/MediaRepositoryImpl.kt
new file mode 100644
index 0000000..454b4c4
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/infrastructure/persistence/local/MediaRepositoryImpl.kt
@@ -0,0 +1,542 @@
+package com.trendhive.arsample.infrastructure.persistence.local
+
+import android.content.ContentValues
+import android.content.Context
+import android.graphics.BitmapFactory
+import android.media.MediaMetadataRetriever
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.view.Surface
+import com.trendhive.arsample.domain.exception.StorageException
+import com.trendhive.arsample.domain.exception.ValidationException
+import com.trendhive.arsample.domain.model.CapturedPhoto
+import com.trendhive.arsample.domain.model.CapturedVideo
+import com.trendhive.arsample.domain.model.currentTimeMillis
+import com.trendhive.arsample.domain.repository.MediaRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Android implementation of MediaRepository.
+ * Uses MediaStore API for Android 10+ and direct file access for older versions.
+ *
+ * Video recording is managed through a surface-based approach, requiring
+ * the AR view to provide a recording surface via [setRecordingSurface].
+ */
+class MediaRepositoryImpl(
+ private val context: Context
+) : MediaRepository {
+
+ companion object {
+ private const val APP_SUBFOLDER = "ARSample"
+ }
+
+ // Video recording state
+ private val _isRecording = AtomicBoolean(false)
+ private var recordingStartTime: Long = 0L
+ private var currentRecordingPath: String? = null
+
+ // Callback for starting/stopping recording on the AR surface
+ private var onStartRecordingCallback: ((String) -> Boolean)? = null
+ private var onStopRecordingCallback: (() -> Boolean)? = null
+
+ /**
+ * Set callbacks for video recording.
+ * The AR view should provide these to handle the actual recording.
+ * @param onStart Called when startVideoRecording is invoked, receives output path, returns success
+ * @param onStop Called when stopVideoRecording is invoked, returns success
+ */
+ fun setRecordingCallbacks(
+ onStart: (String) -> Boolean,
+ onStop: () -> Boolean
+ ) {
+ onStartRecordingCallback = onStart
+ onStopRecordingCallback = onStop
+ }
+
+ /**
+ * Clear recording callbacks (e.g., when AR view is destroyed).
+ */
+ fun clearRecordingCallbacks() {
+ onStartRecordingCallback = null
+ onStopRecordingCallback = null
+ }
+
+ override suspend fun savePhoto(imageData: ByteArray, filename: String): Result =
+ withContext(Dispatchers.IO) {
+ try {
+ val (filePath, width, height) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveWithMediaStore(imageData, filename)
+ } else {
+ saveWithDirectAccess(imageData, filename)
+ }
+
+ val photo = CapturedPhoto(
+ id = UUID.randomUUID().toString(),
+ filePath = filePath,
+ timestamp = currentTimeMillis(),
+ width = width,
+ height = height
+ )
+
+ Result.success(photo)
+ } catch (e: Exception) {
+ Result.failure(StorageException("Failed to save photo: ${e.message}", e))
+ }
+ }
+
+ /**
+ * Save using MediaStore API (Android 10+).
+ * Photos are saved to Pictures/ARSample folder.
+ */
+ private fun saveWithMediaStore(imageData: ByteArray, filename: String): Triple {
+ val contentValues = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
+ put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
+ put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/$APP_SUBFOLDER")
+ }
+
+ val resolver = context.contentResolver
+ val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
+ ?: throw StorageException("Failed to create MediaStore entry")
+
+ resolver.openOutputStream(uri)?.use { outputStream ->
+ outputStream.write(imageData)
+ } ?: throw StorageException("Failed to open output stream for MediaStore")
+
+ // Get image dimensions
+ val (width, height) = getImageDimensions(imageData)
+
+ return Triple(uri.toString(), width, height)
+ }
+
+ /**
+ * Save with direct file access (Android 9 and below).
+ */
+ @Suppress("DEPRECATION")
+ private fun saveWithDirectAccess(imageData: ByteArray, filename: String): Triple {
+ val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
+ val appDir = File(picturesDir, APP_SUBFOLDER)
+
+ if (!appDir.exists() && !appDir.mkdirs()) {
+ throw StorageException("Failed to create pictures directory")
+ }
+
+ val file = File(appDir, filename)
+ file.writeBytes(imageData)
+
+ // Notify media scanner for older Android versions
+ android.media.MediaScannerConnection.scanFile(
+ context,
+ arrayOf(file.absolutePath),
+ arrayOf("image/jpeg"),
+ null
+ )
+
+ val (width, height) = getImageDimensions(imageData)
+
+ return Triple(file.absolutePath, width, height)
+ }
+
+ /**
+ * Get image dimensions from raw JPEG data.
+ */
+ private fun getImageDimensions(imageData: ByteArray): Pair {
+ val options = BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ BitmapFactory.decodeByteArray(imageData, 0, imageData.size, options)
+ return Pair(options.outWidth, options.outHeight)
+ }
+
+ override suspend fun getPhotos(): Result> = withContext(Dispatchers.IO) {
+ try {
+ val photos = mutableListOf()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val projection = arrayOf(
+ MediaStore.Images.Media._ID,
+ MediaStore.Images.Media.DISPLAY_NAME,
+ MediaStore.Images.Media.DATE_ADDED,
+ MediaStore.Images.Media.WIDTH,
+ MediaStore.Images.Media.HEIGHT,
+ MediaStore.Images.Media.RELATIVE_PATH
+ )
+
+ val selection = "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?"
+ val selectionArgs = arrayOf("${Environment.DIRECTORY_PICTURES}/$APP_SUBFOLDER%")
+ val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
+
+ context.contentResolver.query(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder
+ )?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
+ val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
+ val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
+ val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.WIDTH)
+ val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.HEIGHT)
+
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idColumn)
+ val contentUri = android.content.ContentUris.withAppendedId(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ id
+ )
+
+ photos.add(
+ CapturedPhoto(
+ id = id.toString(),
+ filePath = contentUri.toString(),
+ timestamp = cursor.getLong(dateColumn) * 1000, // Convert to millis
+ width = cursor.getInt(widthColumn),
+ height = cursor.getInt(heightColumn)
+ )
+ )
+ }
+ }
+ } else {
+ // Direct file access for older versions
+ @Suppress("DEPRECATION")
+ val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
+ val appDir = File(picturesDir, APP_SUBFOLDER)
+
+ if (appDir.exists()) {
+ appDir.listFiles()
+ ?.filter { it.extension.lowercase() in listOf("jpg", "jpeg") }
+ ?.sortedByDescending { it.lastModified() }
+ ?.forEach { file ->
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(file.absolutePath, options)
+
+ photos.add(
+ CapturedPhoto(
+ id = file.name,
+ filePath = file.absolutePath,
+ timestamp = file.lastModified(),
+ width = options.outWidth,
+ height = options.outHeight
+ )
+ )
+ }
+ }
+ }
+
+ Result.success(photos)
+ } catch (e: Exception) {
+ Result.failure(StorageException("Failed to load photos: ${e.message}", e))
+ }
+ }
+
+ override suspend fun deletePhoto(id: String): Result = withContext(Dispatchers.IO) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val uri = android.content.ContentUris.withAppendedId(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ id.toLongOrNull() ?: throw StorageException("Invalid photo ID: $id")
+ )
+ val deleted = context.contentResolver.delete(uri, null, null)
+ if (deleted == 0) {
+ return@withContext Result.failure(StorageException("Photo not found: $id"))
+ }
+ } else {
+ // Direct file deletion for older versions
+ val file = File(id)
+ if (!file.exists()) {
+ return@withContext Result.failure(StorageException("Photo not found: $id"))
+ }
+ if (!file.delete()) {
+ return@withContext Result.failure(StorageException("Failed to delete photo: $id"))
+ }
+ }
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(StorageException("Failed to delete photo: ${e.message}", e))
+ }
+ }
+
+ // ==================== Video Operations ====================
+
+ override suspend fun startVideoRecording(): Result = withContext(Dispatchers.IO) {
+ try {
+ if (_isRecording.get()) {
+ return@withContext Result.failure(ValidationException("Video recording is already in progress"))
+ }
+
+ val startCallback = onStartRecordingCallback
+ ?: return@withContext Result.failure(StorageException("Recording not available - AR view not configured"))
+
+ // Generate output path
+ val timestamp = currentTimeMillis()
+ val filename = "AR_Video_$timestamp.mp4"
+ val outputPath = getVideoOutputPath(filename)
+
+ // Start recording via callback
+ val success = startCallback(outputPath)
+ if (!success) {
+ return@withContext Result.failure(StorageException("Failed to start video recording"))
+ }
+
+ _isRecording.set(true)
+ recordingStartTime = timestamp
+ currentRecordingPath = outputPath
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ _isRecording.set(false)
+ currentRecordingPath = null
+ Result.failure(StorageException("Failed to start video recording: ${e.message}", e))
+ }
+ }
+
+ override suspend fun stopVideoRecording(): Result = withContext(Dispatchers.IO) {
+ try {
+ if (!_isRecording.get()) {
+ return@withContext Result.failure(ValidationException("No video recording in progress"))
+ }
+
+ val stopCallback = onStopRecordingCallback
+ ?: return@withContext Result.failure(StorageException("Recording not available - AR view not configured"))
+
+ val recordingPath = currentRecordingPath
+ ?: return@withContext Result.failure(StorageException("Recording path not found"))
+
+ // Stop recording via callback
+ val success = stopCallback()
+ if (!success) {
+ return@withContext Result.failure(StorageException("Failed to stop video recording"))
+ }
+
+ _isRecording.set(false)
+
+ // Calculate duration
+ val durationMs = currentTimeMillis() - recordingStartTime
+
+ // Get video metadata
+ val (width, height, actualDuration) = getVideoMetadata(recordingPath)
+
+ // Register with MediaStore if on Android 10+
+ val finalPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ registerVideoWithMediaStore(recordingPath)
+ } else {
+ scanVideoFile(recordingPath)
+ recordingPath
+ }
+
+ val video = CapturedVideo(
+ id = UUID.randomUUID().toString(),
+ filePath = finalPath,
+ timestamp = recordingStartTime,
+ durationMs = actualDuration ?: durationMs,
+ width = width,
+ height = height
+ )
+
+ currentRecordingPath = null
+ recordingStartTime = 0L
+
+ Result.success(video)
+ } catch (e: Exception) {
+ _isRecording.set(false)
+ currentRecordingPath = null
+ recordingStartTime = 0L
+ Result.failure(StorageException("Failed to stop video recording: ${e.message}", e))
+ }
+ }
+
+ override suspend fun getVideos(): Result> = withContext(Dispatchers.IO) {
+ try {
+ val videos = mutableListOf()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val projection = arrayOf(
+ MediaStore.Video.Media._ID,
+ MediaStore.Video.Media.DISPLAY_NAME,
+ MediaStore.Video.Media.DATE_ADDED,
+ MediaStore.Video.Media.DURATION,
+ MediaStore.Video.Media.WIDTH,
+ MediaStore.Video.Media.HEIGHT,
+ MediaStore.Video.Media.RELATIVE_PATH
+ )
+
+ val selection = "${MediaStore.Video.Media.RELATIVE_PATH} LIKE ?"
+ val selectionArgs = arrayOf("${Environment.DIRECTORY_MOVIES}/$APP_SUBFOLDER%")
+ val sortOrder = "${MediaStore.Video.Media.DATE_ADDED} DESC"
+
+ context.contentResolver.query(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder
+ )?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
+ val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)
+ val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
+ val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.WIDTH)
+ val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.HEIGHT)
+
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idColumn)
+ val contentUri = android.content.ContentUris.withAppendedId(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ id
+ )
+
+ videos.add(
+ CapturedVideo(
+ id = id.toString(),
+ filePath = contentUri.toString(),
+ timestamp = cursor.getLong(dateColumn) * 1000,
+ durationMs = cursor.getLong(durationColumn),
+ width = cursor.getInt(widthColumn),
+ height = cursor.getInt(heightColumn)
+ )
+ )
+ }
+ }
+ } else {
+ // Direct file access for older versions
+ @Suppress("DEPRECATION")
+ val moviesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
+ val appDir = File(moviesDir, APP_SUBFOLDER)
+
+ if (appDir.exists()) {
+ appDir.listFiles()
+ ?.filter { it.extension.lowercase() == "mp4" }
+ ?.sortedByDescending { it.lastModified() }
+ ?.forEach { file ->
+ val (width, height, duration) = getVideoMetadata(file.absolutePath)
+
+ videos.add(
+ CapturedVideo(
+ id = file.name,
+ filePath = file.absolutePath,
+ timestamp = file.lastModified(),
+ durationMs = duration ?: 0L,
+ width = width,
+ height = height
+ )
+ )
+ }
+ }
+ }
+
+ Result.success(videos)
+ } catch (e: Exception) {
+ Result.failure(StorageException("Failed to load videos: ${e.message}", e))
+ }
+ }
+
+ override suspend fun deleteVideo(id: String): Result = withContext(Dispatchers.IO) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val uri = android.content.ContentUris.withAppendedId(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ id.toLongOrNull() ?: throw StorageException("Invalid video ID: $id")
+ )
+ val deleted = context.contentResolver.delete(uri, null, null)
+ if (deleted == 0) {
+ return@withContext Result.failure(StorageException("Video not found: $id"))
+ }
+ } else {
+ val file = File(id)
+ if (!file.exists()) {
+ return@withContext Result.failure(StorageException("Video not found: $id"))
+ }
+ if (!file.delete()) {
+ return@withContext Result.failure(StorageException("Failed to delete video: $id"))
+ }
+ }
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(StorageException("Failed to delete video: ${e.message}", e))
+ }
+ }
+
+ override fun isRecording(): Boolean = _isRecording.get()
+
+ // ==================== Video Helper Methods ====================
+
+ /**
+ * Get video output path based on Android version.
+ */
+ private fun getVideoOutputPath(filename: String): String {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Use app's cache directory for temporary recording, then move to MediaStore
+ File(context.cacheDir, filename).absolutePath
+ } else {
+ @Suppress("DEPRECATION")
+ val moviesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
+ val appDir = File(moviesDir, APP_SUBFOLDER)
+ if (!appDir.exists()) appDir.mkdirs()
+ File(appDir, filename).absolutePath
+ }
+ }
+
+ /**
+ * Get video metadata (width, height, duration) using MediaMetadataRetriever.
+ */
+ private fun getVideoMetadata(filePath: String): Triple {
+ return try {
+ MediaMetadataRetriever().use { retriever ->
+ retriever.setDataSource(filePath)
+ val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
+ val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
+ val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
+ Triple(width, height, duration)
+ }
+ } catch (e: Exception) {
+ Triple(0, 0, null)
+ }
+ }
+
+ /**
+ * Register video with MediaStore (Android 10+).
+ * Moves from cache to MediaStore and returns content URI.
+ */
+ private fun registerVideoWithMediaStore(sourcePath: String): String {
+ val sourceFile = File(sourcePath)
+ val contentValues = ContentValues().apply {
+ put(MediaStore.Video.Media.DISPLAY_NAME, sourceFile.name)
+ put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
+ put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/$APP_SUBFOLDER")
+ }
+
+ val resolver = context.contentResolver
+ val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
+ ?: throw StorageException("Failed to create MediaStore entry for video")
+
+ resolver.openOutputStream(uri)?.use { outputStream ->
+ sourceFile.inputStream().use { inputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ } ?: throw StorageException("Failed to open output stream for video")
+
+ // Delete temporary file
+ sourceFile.delete()
+
+ return uri.toString()
+ }
+
+ /**
+ * Scan video file for older Android versions.
+ */
+ private fun scanVideoFile(filePath: String) {
+ android.media.MediaScannerConnection.scanFile(
+ context,
+ arrayOf(filePath),
+ arrayOf("video/mp4"),
+ null
+ )
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.android.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.android.kt
new file mode 100644
index 0000000..f5e302a
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.android.kt
@@ -0,0 +1,73 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.remember
+import io.github.sceneview.rememberEngine
+import io.github.sceneview.rememberModelLoader
+import com.google.android.filament.Engine
+import io.github.sceneview.loaders.ModelLoader
+
+private const val TAG = "ModelPreviewEngineProvider"
+
+/**
+ * Holds the shared Filament [Engine] and [ModelLoader] for all thumbnail composables
+ * in a single gallery session.
+ *
+ * Only valid inside a [ModelPreviewEngineProvider] composable scope.
+ */
+data class PreviewEngineHolder(
+ val engine: Engine,
+ val modelLoader: ModelLoader
+)
+
+/**
+ * CompositionLocal that provides a shared [PreviewEngineHolder] to all
+ * [ModelPreviewThumbnail] composables nested under [ModelPreviewEngineProvider].
+ *
+ * Accessing this outside a provider scope returns null, which causes the thumbnail
+ * to fall back to a placeholder icon — this is a safe degradation.
+ */
+val LocalPreviewEngine = compositionLocalOf { null }
+
+/**
+ * Provides a single shared Filament [Engine] and [ModelLoader] for all
+ * [ModelPreviewThumbnail] composables in [content].
+ *
+ * Root cause of the previous crash:
+ * Each `ModelPreviewScene` composable created its own [Engine] via `rememberEngine()`.
+ * In a LazyVerticalGrid with multiple visible cards the concurrent EGL surface count
+ * exceeded the Android system limit (~16), crashing the process.
+ *
+ * Fix:
+ * One [Engine] is created here and shared with every thumbnail cell via
+ * [LocalPreviewEngine]. The engine is destroyed when this composable leaves
+ * the composition (i.e., when the user navigates away from the gallery screen).
+ */
+@Composable
+actual fun ModelPreviewEngineProvider(content: @Composable () -> Unit) {
+ val engine = rememberEngine()
+ val modelLoader = rememberModelLoader(engine)
+
+ val holder = remember(engine, modelLoader) {
+ PreviewEngineHolder(engine = engine, modelLoader = modelLoader)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ Log.d(TAG, "Disposing shared preview engine")
+ // Explicitly destroy Filament resources to prevent GL/EGL memory leaks
+ // on repeated gallery open/close cycles.
+ modelLoader.destroy()
+ engine.destroy()
+ }
+ }
+
+ CompositionLocalProvider(
+ LocalPreviewEngine provides holder,
+ content = content
+ )
+}
diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.android.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.android.kt
new file mode 100644
index 0000000..0b52855
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.android.kt
@@ -0,0 +1,252 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import android.util.Log
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ViewInAr
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import io.github.sceneview.Scene
+import io.github.sceneview.math.Position
+import io.github.sceneview.math.Rotation
+import io.github.sceneview.node.ModelNode
+import io.github.sceneview.rememberCameraNode
+import io.github.sceneview.rememberMainLightNode
+import java.io.File
+
+private const val TAG = "ModelPreviewThumbnail"
+
+/**
+ * Android implementation of 3D model preview thumbnail using SceneView.
+ *
+ * Crash root causes and fixes:
+ *
+ * 1. Model loading on wrong thread: createModelInstance() must run on the main thread
+ * (the GL context owner). Fix: LaunchedEffect dispatches on Main by default.
+ *
+ * 2. EGL context exhaustion (PRIMARY CRASH CAUSE for LazyVerticalGrid):
+ * Each Scene composable owns a SurfaceView with its own EGL surface. Android
+ * enforces a system-wide limit (~16 simultaneous EGL surfaces). A LazyVerticalGrid
+ * with multiple visible cards previously called rememberEngine() per cell, creating
+ * one Filament Engine (and SurfaceView) per thumbnail simultaneously, exceeding
+ * the limit and crashing the process.
+ *
+ * Fix: The Filament Engine and ModelLoader are no longer created per-cell.
+ * Instead, they are provided by [ModelPreviewEngineProvider] (a single shared
+ * instance at the gallery screen level via [LocalPreviewEngine]).
+ * If no provider is found in the composition tree the thumbnail falls back to a
+ * static placeholder icon — a safe degradation that never crashes.
+ *
+ * 3. Missing GPU resource cleanup: ModelNode.destroy() is called deterministically
+ * in DisposableEffect when a cell scrolls off screen.
+ */
+@Composable
+actual fun ModelPreviewThumbnail(
+ modelPath: String,
+ modifier: Modifier,
+ autoRotate: Boolean
+) {
+ // Obtain the shared engine from the nearest ModelPreviewEngineProvider ancestor.
+ // If no provider is present in the tree, engineHolder is null and we show the
+ // placeholder icon instead of attempting to create a per-cell Engine (which crashed).
+ val engineHolder = LocalPreviewEngine.current
+
+ if (engineHolder == null) {
+ // Safe fallback: no engine provider wrapping this composable.
+ // Render a static icon instead of crashing.
+ Log.w(TAG, "No ModelPreviewEngineProvider found — showing placeholder for $modelPath")
+ ThumbnailPlaceholder(modifier = modifier)
+ return
+ }
+
+ var isLoading by remember { mutableStateOf(true) }
+ var hasError by remember { mutableStateOf(false) }
+ var modelNode by remember { mutableStateOf(null) }
+
+ val infiniteTransition = rememberInfiniteTransition(label = "modelRotation")
+ val rotationAngle by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 360f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 8000, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "rotation"
+ )
+
+ LaunchedEffect(rotationAngle, autoRotate, modelNode) {
+ if (autoRotate) {
+ modelNode?.rotation = Rotation(y = rotationAngle)
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ when {
+ hasError -> {
+ Icon(
+ imageVector = Icons.Default.ViewInAr,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ }
+ isLoading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+
+ if (!hasError) {
+ key(modelPath) {
+ ModelPreviewScene(
+ modelPath = modelPath,
+ engineHolder = engineHolder,
+ rotationAngle = if (autoRotate) rotationAngle else 0f,
+ onModelLoaded = { node ->
+ modelNode = node
+ isLoading = false
+ },
+ onError = {
+ hasError = true
+ isLoading = false
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Static icon placeholder shown when no [ModelPreviewEngineProvider] is found
+ * in the composition tree, or when the model fails to load.
+ */
+@Composable
+private fun ThumbnailPlaceholder(modifier: Modifier) {
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.ViewInAr,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ }
+}
+
+/**
+ * Internal composable that renders a single 3D model inside a [Scene].
+ *
+ * Consumes the shared [PreviewEngineHolder] provided by [ModelPreviewEngineProvider]
+ * instead of creating its own [Engine]. This is the critical change that prevents
+ * EGL context exhaustion in a LazyVerticalGrid.
+ */
+@Composable
+private fun ModelPreviewScene(
+ modelPath: String,
+ engineHolder: PreviewEngineHolder,
+ rotationAngle: Float,
+ onModelLoaded: (ModelNode) -> Unit,
+ onError: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ // Use the SHARED engine and modelLoader — not per-cell instances.
+ val engine = engineHolder.engine
+ val modelLoader = engineHolder.modelLoader
+
+ val cameraNode = rememberCameraNode(engine) {
+ position = Position(x = 0f, y = 0.4f, z = 2.0f)
+ lookAt(Position(0f, 0f, 0f))
+ }
+
+ val mainLightNode = rememberMainLightNode(engine) {
+ intensity = 80_000f
+ }
+
+ var modelNodeState by remember { mutableStateOf(null) }
+
+ LaunchedEffect(modelPath) {
+ val file = File(modelPath)
+ val initialState = ModelPreviewThumbnailHelper.resolveInitialAndroidState(file.exists())
+ if (initialState == ThumbnailState.Error) {
+ Log.w(TAG, "Model file does not exist: $modelPath")
+ onError()
+ return@LaunchedEffect
+ }
+
+ try {
+ val instance = modelLoader.createModelInstance(modelPath)
+ val finalState = ModelPreviewThumbnailHelper.resolveAndroidStateAfterLoad(instance != null)
+ if (finalState == ThumbnailState.Error || instance == null) {
+ Log.e(TAG, "createModelInstance returned null for: $modelPath")
+ onError()
+ } else {
+ val node = ModelNode(
+ modelInstance = instance,
+ scaleToUnits = 0.5f
+ ).apply {
+ position = Position(0f, 0f, 0f)
+ rotation = Rotation(y = rotationAngle)
+ }
+ modelNodeState = node
+ onModelLoaded(node)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to load model: $modelPath", e)
+ onError()
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ modelNodeState?.destroy()
+ modelNodeState = null
+ }
+ }
+
+ val childNodes = remember(modelNodeState, mainLightNode) {
+ listOfNotNull(modelNodeState, mainLightNode)
+ }
+
+ Scene(
+ modifier = modifier,
+ engine = engine,
+ modelLoader = modelLoader,
+ cameraNode = cameraNode,
+ childNodes = childNodes
+ )
+}
diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.android.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.android.kt
new file mode 100644
index 0000000..48e63ac
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.android.kt
@@ -0,0 +1,87 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.util.Log
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+
+private const val TAG = "PhotoThumbnail"
+
+@Composable
+actual fun PhotoThumbnail(uri: String?, modifier: Modifier) {
+ val context = LocalContext.current
+ var bitmap by remember(uri) { mutableStateOf(null) }
+
+ LaunchedEffect(uri) {
+ if (uri.isNullOrBlank()) {
+ bitmap = null
+ return@LaunchedEffect
+ }
+
+ bitmap = withContext(Dispatchers.IO) {
+ try {
+ val parsedUri = Uri.parse(uri)
+
+ // Handle content:// URIs
+ if (parsedUri.scheme == "content") {
+ val inputStream = context.contentResolver.openInputStream(parsedUri)
+ if (inputStream == null) {
+ Log.w(TAG, "Failed to open content URI: $uri")
+ return@withContext null
+ }
+ inputStream.use { stream ->
+ BitmapFactory.decodeStream(stream)?.asImageBitmap()
+ }
+ }
+ // Handle file:// and absolute paths
+ else {
+ val filePath = if (uri.startsWith("file://")) {
+ uri.substring(7)
+ } else {
+ uri
+ }
+
+ val file = File(filePath)
+ if (!file.exists()) {
+ Log.w(TAG, "File does not exist: $filePath")
+ return@withContext null
+ }
+
+ BitmapFactory.decodeFile(filePath)?.asImageBitmap()
+ }
+ } catch (e: IllegalArgumentException) {
+ Log.e(TAG, "Malformed image data for URI: $uri", e)
+ null
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Permission denied for URI: $uri", e)
+ null
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to decode bitmap for URI: $uri", e)
+ null
+ }
+ }
+ }
+
+ bitmap?.let {
+ Image(
+ bitmap = it,
+ contentDescription = "Last captured photo",
+ contentScale = ContentScale.Crop,
+ modifier = modifier
+ )
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index e13f16f..44f5510 100644
--- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
@@ -28,4 +28,12 @@
Format
İptal
Bir hata oluştu
+ Yerleştirmek için basılı tutun
+ Silmek için bırakın
+ Silmek için buraya sürükleyin
+ Nesne Galerisi
+ Nesne ara…
+ İlk Nesneyi İçe Aktar
+ Sonuç Bulunamadı
+ \"%s\" ile eşleşen nesne yok
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 1d3838e..33e0867 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -28,4 +28,12 @@
Format
Cancel
An error occurred
+ Long press to place
+ Release to delete
+ Drag here to delete
+ Object Gallery
+ Search objects…
+ Import First Object
+ No Results Found
+ No objects match \"%s\"
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/App.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/App.kt
index 558afd7..229232f 100644
--- a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/App.kt
@@ -6,24 +6,18 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
-import com.trendhive.arsample.application.usecase.*
+import com.trendhive.arsample.domain.model.MediaItem
import com.trendhive.arsample.presentation.ui.screens.ARScreen
+import com.trendhive.arsample.presentation.ui.screens.GalleryScreen
+import com.trendhive.arsample.presentation.ui.screens.ObjectGalleryScreen
import com.trendhive.arsample.presentation.ui.screens.ObjectListScreen
import com.trendhive.arsample.presentation.viewmodel.ARViewModel
+import com.trendhive.arsample.presentation.viewmodel.GalleryViewModel
import com.trendhive.arsample.presentation.viewmodel.ObjectListViewModel
+import org.koin.compose.koinInject
@Composable
-fun App(
- importObjectUseCase: ImportObjectUseCase,
- getAllObjectsUseCase: GetAllObjectsUseCase,
- deleteObjectUseCase: DeleteObjectUseCase,
- placeObjectInSceneUseCase: PlaceObjectInSceneUseCase,
- removeObjectFromSceneUseCase: RemoveObjectFromSceneUseCase,
- getSceneUseCase: GetSceneUseCase,
- saveSceneUseCase: SaveSceneUseCase,
- moveObjectUseCase: MoveObjectUseCase,
- sceneRepository: com.trendhive.arsample.domain.repository.ARSceneRepository
-) {
+fun App() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -31,26 +25,11 @@ fun App(
) {
var currentScreen by remember { mutableStateOf(Screen.AR(null)) }
- val objectListViewModel: ObjectListViewModel = viewModel {
- ObjectListViewModel(
- getAllObjectsUseCase,
- deleteObjectUseCase,
- importObjectUseCase
- )
- }
+ // Inject ViewModels via Koin (only inject when needed to avoid premature initialization)
+ val objectListViewModel: ObjectListViewModel = koinInject()
val objectListUiState by objectListViewModel.uiState.collectAsState()
- // Create ARViewModel once, outside the when block
- val arViewModel: ARViewModel = viewModel {
- ARViewModel(
- placeObjectInSceneUseCase,
- removeObjectFromSceneUseCase,
- getSceneUseCase,
- saveSceneUseCase,
- sceneRepository,
- moveObjectUseCase
- )
- }
+ val arViewModel: ARViewModel = koinInject()
val arUiState by arViewModel.uiState.collectAsState()
when (val screen = currentScreen) {
@@ -68,6 +47,46 @@ fun App(
onDeleteObject = { objectListViewModel.deleteObject(it) }
)
}
+ is Screen.ObjectGallery -> {
+ ObjectGalleryScreen(
+ uiState = objectListUiState,
+ onObjectClick = { arObject ->
+ objectListViewModel.clearImportSuccess()
+ currentScreen = Screen.AR(arObject.id)
+ },
+ onObjectDelete = { id ->
+ objectListViewModel.deleteObject(id)
+ },
+ onImportClick = { uri, name, type ->
+ objectListViewModel.importObject(uri, name, type)
+ },
+ onNavigateBack = { currentScreen = Screen.AR(null) },
+ onNavigateToAR = { currentScreen = Screen.AR(null) }
+ )
+ }
+ is Screen.Gallery -> {
+ // Lazy inject GalleryViewModel only when Gallery screen is shown
+ // This prevents premature MediaRepository access before user navigates to Gallery
+ val galleryViewModel: GalleryViewModel = koinInject()
+ val galleryUiState by galleryViewModel.uiState.collectAsState()
+
+ // Refresh media list each time the Gallery screen opens
+ LaunchedEffect(Unit) {
+ galleryViewModel.loadMedia()
+ }
+
+ GalleryScreen(
+ uiState = galleryUiState,
+ onNavigateBack = { currentScreen = Screen.AR(null) },
+ onPhotoClick = { galleryViewModel.selectMedia(MediaItem.Photo(it)) },
+ onVideoClick = { galleryViewModel.selectMedia(MediaItem.Video(it)) },
+ onDeletePhoto = { galleryViewModel.deletePhoto(it) },
+ onDeleteVideo = { galleryViewModel.deleteVideo(it) },
+ onFilterChange = { galleryViewModel.setFilter(it) },
+ onClosePreview = { galleryViewModel.closePreview() },
+ onClearError = { galleryViewModel.clearError() }
+ )
+ }
is Screen.AR -> {
LaunchedEffect(screen.selectedObjectId) {
if (screen.selectedObjectId != null) {
@@ -79,7 +98,7 @@ fun App(
uiState = arUiState,
availableObjects = objectListUiState.objects,
onSelectObject = { arViewModel.selectObject(it) },
- onNavigateBack = { currentScreen = Screen.ObjectList },
+ onNavigateBack = { currentScreen = Screen.ObjectGallery },
onImportObject = { uri, name, type ->
objectListViewModel.importObject(uri, name, type)
},
@@ -110,6 +129,24 @@ fun App(
},
onDragEnd = {
arViewModel.onDragEnd()
+ },
+ onToggleRecording = {
+ arViewModel.toggleRecording()
+ },
+ onClearRecordingState = {
+ arViewModel.clearRecordingState()
+ },
+ onCapturePhoto = {
+ arViewModel.requestCapture()
+ },
+ onOpenGallery = {
+ currentScreen = Screen.Gallery
+ },
+ onPhotoCaptured = { imageData ->
+ arViewModel.onPhotoCaptured(imageData)
+ },
+ onClearShutterFlash = {
+ arViewModel.clearShutterFlash()
}
)
}
@@ -120,5 +157,7 @@ fun App(
sealed class Screen {
data object ObjectList : Screen()
+ data object ObjectGallery : Screen()
+ data object Gallery : Screen()
data class AR(val selectedObjectId: String?) : Screen()
}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/CapturePhotoUseCase.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/CapturePhotoUseCase.kt
new file mode 100644
index 0000000..4f095f0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/CapturePhotoUseCase.kt
@@ -0,0 +1,37 @@
+package com.trendhive.arsample.application.usecase
+
+import com.trendhive.arsample.domain.exception.ValidationException
+import com.trendhive.arsample.domain.model.CapturedPhoto
+import com.trendhive.arsample.domain.model.currentTimeMillis
+import com.trendhive.arsample.domain.repository.MediaRepository
+
+/**
+ * Use case for capturing and saving AR scene photos.
+ * Validates image data before delegating to the repository.
+ */
+class CapturePhotoUseCase(
+ private val mediaRepository: MediaRepository
+) {
+ /**
+ * Capture and save a photo from the AR scene.
+ * @param imageData The raw JPEG image data
+ * @return Result containing the saved CapturedPhoto on success
+ */
+ suspend operator fun invoke(imageData: ByteArray): Result {
+ // Validate image data
+ if (imageData.isEmpty()) {
+ return Result.failure(ValidationException("Image data cannot be empty"))
+ }
+
+ // Minimum size check (a valid JPEG should be at least a few KB)
+ if (imageData.size < 100) {
+ return Result.failure(ValidationException("Image data appears to be invalid (too small)"))
+ }
+
+ // Generate a unique filename with timestamp
+ val timestamp = currentTimeMillis()
+ val filename = "AR_Capture_$timestamp.jpg"
+
+ return mediaRepository.savePhoto(imageData, filename)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/DeletePhotoUseCase.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/DeletePhotoUseCase.kt
new file mode 100644
index 0000000..34c279b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/DeletePhotoUseCase.kt
@@ -0,0 +1,23 @@
+package com.trendhive.arsample.application.usecase
+
+import com.trendhive.arsample.domain.exception.ValidationException
+import com.trendhive.arsample.domain.repository.MediaRepository
+
+/**
+ * Use case for deleting a captured photo.
+ */
+class DeletePhotoUseCase(
+ private val mediaRepository: MediaRepository
+) {
+ /**
+ * Delete a photo by its ID.
+ * @param id The photo's unique identifier
+ * @return Result indicating success or failure
+ */
+ suspend operator fun invoke(id: String): Result {
+ if (id.isBlank()) {
+ return Result.failure(ValidationException("Photo ID cannot be blank"))
+ }
+ return mediaRepository.deletePhoto(id)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/DeleteVideoUseCase.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/DeleteVideoUseCase.kt
new file mode 100644
index 0000000..f063d6c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/DeleteVideoUseCase.kt
@@ -0,0 +1,23 @@
+package com.trendhive.arsample.application.usecase
+
+import com.trendhive.arsample.domain.exception.ValidationException
+import com.trendhive.arsample.domain.repository.MediaRepository
+
+/**
+ * Use case for deleting a captured video.
+ */
+class DeleteVideoUseCase(
+ private val mediaRepository: MediaRepository
+) {
+ /**
+ * Delete a video by its ID.
+ * @param id The video's unique identifier
+ * @return Result indicating success or failure
+ */
+ suspend operator fun invoke(id: String): Result {
+ if (id.isBlank()) {
+ return Result.failure(ValidationException("Video ID cannot be blank"))
+ }
+ return mediaRepository.deleteVideo(id)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/GetPhotosUseCase.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/GetPhotosUseCase.kt
new file mode 100644
index 0000000..31a6546
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/GetPhotosUseCase.kt
@@ -0,0 +1,21 @@
+package com.trendhive.arsample.application.usecase
+
+import com.trendhive.arsample.domain.model.CapturedPhoto
+import com.trendhive.arsample.domain.repository.MediaRepository
+
+/**
+ * Use case for retrieving all captured photos.
+ */
+class GetPhotosUseCase(
+ private val mediaRepository: MediaRepository
+) {
+ /**
+ * Get all captured photos sorted by timestamp (newest first).
+ * @return Result containing the list of photos on success
+ */
+ suspend operator fun invoke(): Result> {
+ return mediaRepository.getPhotos().map { photos ->
+ photos.sortedByDescending { it.timestamp }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/GetVideosUseCase.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/GetVideosUseCase.kt
new file mode 100644
index 0000000..a5c08a3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/GetVideosUseCase.kt
@@ -0,0 +1,21 @@
+package com.trendhive.arsample.application.usecase
+
+import com.trendhive.arsample.domain.model.CapturedVideo
+import com.trendhive.arsample.domain.repository.MediaRepository
+
+/**
+ * Use case for retrieving all captured videos.
+ */
+class GetVideosUseCase(
+ private val mediaRepository: MediaRepository
+) {
+ /**
+ * Get all captured videos sorted by timestamp (newest first).
+ * @return Result containing the list of videos on success
+ */
+ suspend operator fun invoke(): Result> {
+ return mediaRepository.getVideos().map { videos ->
+ videos.sortedByDescending { it.timestamp }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/RecordVideoUseCase.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/RecordVideoUseCase.kt
new file mode 100644
index 0000000..2c250a8
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/application/usecase/RecordVideoUseCase.kt
@@ -0,0 +1,49 @@
+package com.trendhive.arsample.application.usecase
+
+import com.trendhive.arsample.domain.exception.ValidationException
+import com.trendhive.arsample.domain.model.CapturedVideo
+import com.trendhive.arsample.domain.repository.MediaRepository
+
+/**
+ * Use case for recording AR scene videos.
+ * Manages the start/stop recording lifecycle and validates recording state.
+ */
+class RecordVideoUseCase(
+ private val mediaRepository: MediaRepository
+) {
+ /**
+ * Start video recording.
+ * @return Result indicating success or failure
+ * @throws ValidationException if already recording
+ */
+ suspend fun startRecording(): Result {
+ // Check if already recording
+ if (mediaRepository.isRecording()) {
+ return Result.failure(ValidationException("Video recording is already in progress"))
+ }
+
+ return mediaRepository.startVideoRecording()
+ }
+
+ /**
+ * Stop video recording and save the video.
+ * @return Result containing the saved CapturedVideo on success
+ * @throws ValidationException if not currently recording
+ */
+ suspend fun stopRecording(): Result {
+ // Check if not recording
+ if (!mediaRepository.isRecording()) {
+ return Result.failure(ValidationException("No video recording in progress"))
+ }
+
+ return mediaRepository.stopVideoRecording()
+ }
+
+ /**
+ * Check if video recording is currently in progress.
+ * @return true if recording, false otherwise
+ */
+ fun isRecording(): Boolean {
+ return mediaRepository.isRecording()
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/ar/PlatformARView.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/ar/PlatformARView.kt
index 8145ffc..df5d81c 100644
--- a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/ar/PlatformARView.kt
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/ar/PlatformARView.kt
@@ -15,5 +15,10 @@ expect fun PlatformARView(
onObjectPositionChanged: ((placedObjectId: String, x: Float, y: Float, z: Float) -> Unit)? = null,
onDragStart: ((objectId: String) -> Unit)? = null,
onDragMove: ((objectId: String, screenX: Float, screenY: Float) -> Unit)? = null,
- onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)? = null
+ onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)? = null,
+ captureRequest: Boolean = false,
+ onCaptureComplete: ((ByteArray?) -> Unit)? = null,
+ // Video recording callbacks - set by MediaRepository to enable recording
+ onRecordingCallbacksReady: ((onStart: (String) -> Boolean, onStop: () -> Boolean) -> Unit)? = null,
+ onRecordingCallbacksClear: (() -> Unit)? = null
)
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/di/AppModule.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/di/AppModule.kt
new file mode 100644
index 0000000..135c163
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/di/AppModule.kt
@@ -0,0 +1,102 @@
+package com.trendhive.arsample.di
+
+import com.trendhive.arsample.domain.repository.ARObjectRepository
+import com.trendhive.arsample.domain.repository.ARSceneRepository
+import com.trendhive.arsample.domain.repository.MediaRepository
+import com.trendhive.arsample.infrastructure.persistence.repository.ARObjectRepositoryImpl
+import com.trendhive.arsample.infrastructure.persistence.repository.ARSceneRepositoryImpl
+import com.trendhive.arsample.infrastructure.persistence.mapper.ARObjectMapper
+import com.trendhive.arsample.infrastructure.persistence.mapper.ARSceneMapper
+import com.trendhive.arsample.application.usecase.CapturePhotoUseCase
+import com.trendhive.arsample.application.usecase.DeletePhotoUseCase
+import com.trendhive.arsample.application.usecase.DeleteVideoUseCase
+import com.trendhive.arsample.application.usecase.GetPhotosUseCase
+import com.trendhive.arsample.application.usecase.GetVideosUseCase
+import com.trendhive.arsample.application.usecase.ImportObjectUseCase
+import com.trendhive.arsample.application.usecase.GetAllObjectsUseCase
+import com.trendhive.arsample.application.usecase.DeleteObjectUseCase
+import com.trendhive.arsample.application.usecase.PlaceObjectInSceneUseCase
+import com.trendhive.arsample.application.usecase.RemoveObjectFromSceneUseCase
+import com.trendhive.arsample.application.usecase.GetSceneUseCase
+import com.trendhive.arsample.application.usecase.SaveSceneUseCase
+import com.trendhive.arsample.application.usecase.MoveObjectUseCase
+import com.trendhive.arsample.application.usecase.RecordVideoUseCase
+import com.trendhive.arsample.presentation.viewmodel.GalleryViewModel
+import com.trendhive.arsample.presentation.viewmodel.ObjectListViewModel
+import com.trendhive.arsample.presentation.viewmodel.ARViewModel
+import org.koin.dsl.module
+
+/**
+ * Data layer module - Repository implementations and mappers
+ */
+val dataModule = module {
+ // Mappers
+ single { ARObjectMapper() }
+ single { ARSceneMapper() }
+
+ // Repositories
+ single {
+ ARObjectRepositoryImpl(get(), get())
+ }
+ single {
+ ARSceneRepositoryImpl(get())
+ }
+ // Note: MediaRepository is provided by platformModule (platform-specific implementation)
+}
+
+/**
+ * Application layer module - Use cases
+ */
+val applicationModule = module {
+ // Object use cases
+ factory { ImportObjectUseCase(get()) }
+ factory { GetAllObjectsUseCase(get()) }
+ factory { DeleteObjectUseCase(get()) }
+
+ // Scene use cases
+ factory { PlaceObjectInSceneUseCase(get(), get()) }
+ factory { RemoveObjectFromSceneUseCase(get()) }
+ factory { GetSceneUseCase(get()) }
+ factory { SaveSceneUseCase(get()) }
+ factory { MoveObjectUseCase(get()) }
+
+ // Media use cases
+ factory { CapturePhotoUseCase(get()) }
+ factory { RecordVideoUseCase(get()) }
+ factory { GetPhotosUseCase(get()) }
+ factory { GetVideosUseCase(get()) }
+ factory { DeletePhotoUseCase(get()) }
+ factory { DeleteVideoUseCase(get()) }
+}
+
+/**
+ * Presentation layer module - ViewModels
+ */
+val presentationModule = module {
+ single { ObjectListViewModel(get(), get(), get()) }
+ single {
+ ARViewModel(
+ placeObjectUseCase = get(),
+ removeObjectUseCase = get(),
+ getSceneUseCase = get(),
+ saveSceneUseCase = get(),
+ sceneRepository = get(),
+ moveObjectUseCase = get(),
+ capturePhotoUseCase = get(),
+ recordVideoUseCase = get(),
+ getPhotosUseCase = get()
+ )
+ }
+ // GalleryViewModel registered as single to prevent a new instance (and leaking CoroutineScope)
+ // from being created on every recomposition when koinInject() is called inside the Gallery branch.
+ single { GalleryViewModel(get(), get(), get(), get()) }
+}
+
+/**
+ * Combined app modules
+ */
+val appModules = listOf(
+ dataModule,
+ applicationModule,
+ presentationModule
+)
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/di/PlatformModule.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/di/PlatformModule.kt
new file mode 100644
index 0000000..afd2fcc
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/di/PlatformModule.kt
@@ -0,0 +1,11 @@
+package com.trendhive.arsample.di
+
+import com.trendhive.arsample.infrastructure.persistence.local.ARObjectLocalDataSource
+import com.trendhive.arsample.infrastructure.persistence.local.ARSceneDataStore
+import com.trendhive.arsample.infrastructure.persistence.local.ModelFileStorage
+
+/**
+ * Platform-specific data sources provider
+ * Implemented in androidMain and iosMain
+ */
+expect fun platformDataSourceModule(): org.koin.core.module.Module
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/domain/model/CapturedMedia.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/domain/model/CapturedMedia.kt
new file mode 100644
index 0000000..eb0ac1e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/domain/model/CapturedMedia.kt
@@ -0,0 +1,50 @@
+package com.trendhive.arsample.domain.model
+
+import com.trendhive.arsample.domain.base.BaseModel
+
+/**
+ * Represents a captured photo from an AR scene.
+ * Stored in the device's pictures directory.
+ */
+data class CapturedPhoto(
+ val id: String,
+ val filePath: String,
+ val timestamp: Long,
+ val width: Int,
+ val height: Int
+) : BaseModel
+
+/**
+ * Represents a captured video from an AR scene.
+ * Stored in the device's movies directory.
+ */
+data class CapturedVideo(
+ val id: String,
+ val filePath: String,
+ val timestamp: Long,
+ val durationMs: Long,
+ val width: Int,
+ val height: Int
+) : BaseModel
+
+/**
+ * Represents a media item that can be either a photo or video.
+ * Used for displaying mixed media in gallery views.
+ */
+sealed class MediaItem : BaseModel {
+ abstract val id: String
+ abstract val filePath: String
+ abstract val timestamp: Long
+
+ data class Photo(val photo: CapturedPhoto) : MediaItem() {
+ override val id: String get() = photo.id
+ override val filePath: String get() = photo.filePath
+ override val timestamp: Long get() = photo.timestamp
+ }
+
+ data class Video(val video: CapturedVideo) : MediaItem() {
+ override val id: String get() = video.id
+ override val filePath: String get() = video.filePath
+ override val timestamp: Long get() = video.timestamp
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/domain/repository/MediaRepository.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/domain/repository/MediaRepository.kt
new file mode 100644
index 0000000..9973b40
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/domain/repository/MediaRepository.kt
@@ -0,0 +1,66 @@
+package com.trendhive.arsample.domain.repository
+
+import com.trendhive.arsample.domain.base.BaseRepository
+import com.trendhive.arsample.domain.model.CapturedPhoto
+import com.trendhive.arsample.domain.model.CapturedVideo
+
+/**
+ * Repository interface for media operations (photos and videos captured from AR scenes).
+ */
+interface MediaRepository : BaseRepository {
+ // ==================== Photo Operations ====================
+
+ /**
+ * Save a photo to the device's pictures directory.
+ * @param imageData The raw image data as bytes (JPEG format expected)
+ * @param filename The desired filename for the photo
+ * @return Result containing the CapturedPhoto on success or an exception on failure
+ */
+ suspend fun savePhoto(imageData: ByteArray, filename: String): Result
+
+ /**
+ * Get all captured photos.
+ * @return Result containing a list of CapturedPhoto on success
+ */
+ suspend fun getPhotos(): Result>
+
+ /**
+ * Delete a captured photo by ID.
+ * @param id The photo's unique identifier
+ * @return Result indicating success or failure
+ */
+ suspend fun deletePhoto(id: String): Result
+
+ // ==================== Video Operations ====================
+
+ /**
+ * Start video recording from the AR scene.
+ * @return Result indicating success or failure
+ */
+ suspend fun startVideoRecording(): Result
+
+ /**
+ * Stop video recording and save the video.
+ * @return Result containing the CapturedVideo on success or an exception on failure
+ */
+ suspend fun stopVideoRecording(): Result
+
+ /**
+ * Get all captured videos.
+ * @return Result containing a list of CapturedVideo on success
+ */
+ suspend fun getVideos(): Result>
+
+ /**
+ * Delete a captured video by ID.
+ * @param id The video's unique identifier
+ * @return Result indicating success or failure
+ */
+ suspend fun deleteVideo(id: String): Result
+
+ /**
+ * Check if video recording is currently in progress.
+ * @return true if recording, false otherwise
+ */
+ fun isRecording(): Boolean
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/CameraControls.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/CameraControls.kt
new file mode 100644
index 0000000..3d23d04
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/CameraControls.kt
@@ -0,0 +1,332 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Collections
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/**
+ * Camera-style record button with white outer ring and red inner circle.
+ * When recording, inner circle becomes a red square (stop icon).
+ * Includes press animation (scale down to 0.9f).
+ */
+@Composable
+fun CameraStyleRecordButton(
+ isRecording: Boolean,
+ onToggleRecording: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+
+ // Scale animation on press
+ val scale by animateFloatAsState(
+ targetValue = if (isPressed) 0.9f else 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ ),
+ label = "record_button_scale"
+ )
+
+ // Animate inner shape transformation (circle to square)
+ val innerCornerRadius by animateFloatAsState(
+ targetValue = if (isRecording) 4f else 50f,
+ animationSpec = tween(durationMillis = 200),
+ label = "inner_shape"
+ )
+
+ // Inner size relative to outer - larger when idle, smaller square when recording
+ val innerSizeRatio by animateFloatAsState(
+ targetValue = if (isRecording) 0.35f else 0.65f,
+ animationSpec = tween(durationMillis = 200),
+ label = "inner_size"
+ )
+
+ Box(
+ modifier = modifier
+ .scale(scale)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.2f))
+ .border(
+ width = 3.dp,
+ color = Color.White,
+ shape = CircleShape
+ )
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onToggleRecording
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ // Inner red circle/square - size calculated from parent
+ Box(
+ modifier = Modifier
+ .fillMaxSize(innerSizeRatio)
+ .clip(RoundedCornerShape(if (isRecording) 4.dp else 50.dp))
+ .background(Color(0xFFFF0000))
+ )
+ }
+}
+
+/**
+ * Recording timer display showing elapsed time in HH:MM:SS format.
+ * Features a pulsing red dot indicator and semi-transparent red background.
+ */
+@Composable
+fun RecordingTimerDisplay(
+ durationSeconds: Long,
+ modifier: Modifier = Modifier
+) {
+ val infiniteTransition = rememberInfiniteTransition(label = "timer_pulse")
+ val dotAlpha by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 0.3f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 800),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "dot_pulse"
+ )
+
+ // Format duration as HH:MM:SS
+ val hours = durationSeconds / 3600
+ val minutes = (durationSeconds % 3600) / 60
+ val seconds = durationSeconds % 60
+ val timeString = "${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
+
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(8.dp),
+ color = Color(0xFFFF0000).copy(alpha = 0.85f)
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ // Pulsing red dot
+ Box(
+ modifier = Modifier
+ .size(10.dp)
+ .alpha(dotAlpha)
+ .background(
+ color = Color.White,
+ shape = CircleShape
+ )
+ )
+
+ // REC text
+ Text(
+ text = "REC",
+ style = MaterialTheme.typography.labelMedium.copy(
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp
+ ),
+ color = Color.White
+ )
+
+ // Timer
+ Text(
+ text = timeString,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = FontFamily.Monospace
+ ),
+ color = Color.White
+ )
+ }
+ }
+}
+
+/**
+ * Secondary camera control button (for photo capture, gallery, camera switch).
+ */
+@Composable
+fun CameraControlButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ Box(
+ modifier = modifier
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.2f))
+ .clickable(enabled = enabled, onClick = onClick),
+ contentAlignment = Alignment.Center
+ ) {
+ content()
+ }
+}
+
+/**
+ * Complete camera controls bar with photo capture, record button, and gallery thumbnail.
+ * Professional camera-style layout with perfect symmetry using weighted sections.
+ *
+ * Layout: [Left — Gallery thumbnail] --- [Center — Record] --- [Right — Capture photo]
+ * Left: Last captured photo as circular thumbnail (or gallery icon if none). Tap → gallery.
+ * Center: Large record button (fixed size)
+ * Right: Photo capture button (aligned to start)
+ */
+@Composable
+fun CameraControlsBar(
+ isRecording: Boolean,
+ onCapturePhoto: () -> Unit,
+ onToggleRecording: () -> Unit,
+ onOpenGallery: () -> Unit,
+ lastPhotoData: ByteArray? = null,
+ lastPhotoUri: String? = null,
+ modifier: Modifier = Modifier
+) {
+ // lastPhotoData kept for API compatibility but lastPhotoUri takes precedence
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Left section - Gallery thumbnail (aligned to end of this section)
+ Box(
+ modifier = Modifier.weight(1f),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(end = 24.dp)
+ .size(56.dp)
+ .clip(CircleShape)
+ .border(2.dp, if (isRecording) Color.Gray else Color.White, CircleShape)
+ .clickable(enabled = !isRecording, onClick = onOpenGallery)
+ ) {
+ if (lastPhotoUri != null) {
+ // Persistent thumbnail: loaded from file/content URI
+ PhotoThumbnail(
+ uri = lastPhotoUri,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Collections,
+ contentDescription = "Open Gallery",
+ tint = if (isRecording) Color.Gray else Color.White,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+ }
+ }
+
+ // Center section - Main record button (fixed size, no weight)
+ CameraStyleRecordButton(
+ isRecording = isRecording,
+ onToggleRecording = onToggleRecording,
+ modifier = Modifier.size(80.dp)
+ )
+
+ // Right section - Photo capture button (aligned to start of this section)
+ Box(
+ modifier = Modifier.weight(1f),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ CameraControlButton(
+ onClick = onCapturePhoto,
+ modifier = Modifier
+ .padding(start = 24.dp)
+ .size(56.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.CameraAlt,
+ contentDescription = "Capture Photo",
+ tint = Color.White,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Recording border glow effect - subtle red border when recording.
+ */
+@Composable
+fun BoxScope.RecordingBorderGlow(
+ isRecording: Boolean
+) {
+ val infiniteTransition = rememberInfiniteTransition(label = "border_glow")
+ val glowAlpha by infiniteTransition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 0.6f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1000),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "glow_alpha"
+ )
+
+ AnimatedVisibility(
+ visible = isRecording,
+ enter = fadeIn(tween(300)),
+ exit = fadeOut(tween(300)),
+ modifier = Modifier.matchParentSize()
+ ) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .border(
+ width = 3.dp,
+ color = Color.Red.copy(alpha = glowAlpha),
+ shape = RoundedCornerShape(0.dp)
+ )
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.kt
new file mode 100644
index 0000000..3497076
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.kt
@@ -0,0 +1,19 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Provides the platform-specific 3D rendering engine for [ModelPreviewThumbnail] composables.
+ *
+ * Wrap any composable subtree that contains [ModelPreviewThumbnail] calls with this
+ * provider so that all thumbnails share a single engine instance instead of each
+ * creating their own.
+ *
+ * Platform behaviour:
+ * - Android: Provides a shared Filament [Engine] + [ModelLoader] via [LocalPreviewEngine].
+ * Without this wrapper every thumbnail creates its own Engine, which exhausts the
+ * Android EGL surface limit and crashes the process.
+ * - iOS: No-op (iOS thumbnails do not use a shared rendering engine).
+ */
+@Composable
+expect fun ModelPreviewEngineProvider(content: @Composable () -> Unit)
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.kt
new file mode 100644
index 0000000..ce742de
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.kt
@@ -0,0 +1,22 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/**
+ * Platform-specific 3D model preview thumbnail component.
+ *
+ * Displays a small interactive 3D preview of a GLB/GLTF model.
+ * - Android: Uses SceneView for hardware-accelerated 3D rendering with auto-rotation
+ * - iOS: Shows a placeholder icon (SceneView not available on iOS)
+ *
+ * @param modelPath Path to the 3D model file (supports GLB/GLTF formats)
+ * @param modifier Modifier for sizing and layout (recommended: ~80x80dp)
+ * @param autoRotate Whether to slowly rotate the model for visual appeal
+ */
+@Composable
+expect fun ModelPreviewThumbnail(
+ modelPath: String,
+ modifier: Modifier = Modifier,
+ autoRotate: Boolean = true
+)
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnailHelper.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnailHelper.kt
new file mode 100644
index 0000000..a5267cd
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnailHelper.kt
@@ -0,0 +1,92 @@
+package com.trendhive.arsample.presentation.ui.components
+
+/**
+ * Pure helper functions for ModelPreviewThumbnail logic.
+ *
+ * Extracted from platform-specific Composable implementations to enable
+ * unit testing of routing and state decisions without Compose or platform SDKs.
+ */
+object ModelPreviewThumbnailHelper {
+
+ /**
+ * Resolves the preview strategy for a given model file path based on its extension.
+ *
+ * Mirrors the iOS `ModelPreviewThumbnail` routing logic:
+ * - "usdz" -> USDZ (SceneKit / SCNView)
+ * - "glb", "gltf" -> GLB_THUMBNAIL (placeholder icon; QLThumbnailGenerator not available in Kotlin/Native cinterop)
+ * - anything else -> PLACEHOLDER
+ *
+ * @param modelPath Full path or URI of the 3D model file.
+ * @return [PreviewStrategy] enum indicating which renderer should be used.
+ */
+ fun resolvePreviewStrategy(modelPath: String): PreviewStrategy {
+ val extension = modelPath.substringAfterLast('.', "").lowercase()
+ return when (extension) {
+ "usdz" -> PreviewStrategy.USDZ
+ "glb", "gltf" -> PreviewStrategy.GLB_THUMBNAIL
+ else -> PreviewStrategy.PLACEHOLDER
+ }
+ }
+
+ /**
+ * Determines the initial Android thumbnail state based on whether the model file exists.
+ *
+ * Mirrors the Android `ModelPreviewScene` LaunchedEffect file-existence guard:
+ * - File missing -> [ThumbnailState.Error]
+ * - File present -> [ThumbnailState.Loading] (actual load happens asynchronously)
+ *
+ * @param fileExists Whether the model file is present on disk.
+ * @return The initial [ThumbnailState] for the thumbnail composable.
+ */
+ fun resolveInitialAndroidState(fileExists: Boolean): ThumbnailState {
+ return if (fileExists) ThumbnailState.Loading else ThumbnailState.Error
+ }
+
+ /**
+ * Determines the Android thumbnail state after a model load attempt.
+ *
+ * @param instanceLoaded True when `modelLoader.createModelInstance()` returned non-null.
+ * @return [ThumbnailState.Loaded] on success, [ThumbnailState.Error] on failure.
+ */
+ fun resolveAndroidStateAfterLoad(instanceLoaded: Boolean): ThumbnailState {
+ return if (instanceLoaded) ThumbnailState.Loaded else ThumbnailState.Error
+ }
+
+ /**
+ * Extracts the lowercase file extension from a model path.
+ *
+ * @param modelPath Full path or URI of the 3D model file.
+ * @return The lowercase extension string, or an empty string if absent.
+ */
+ fun extractExtension(modelPath: String): String {
+ return modelPath.substringAfterLast('.', "").lowercase()
+ }
+}
+
+/**
+ * Preview strategy variants for the iOS ModelPreviewThumbnail routing.
+ */
+enum class PreviewStrategy {
+ /** USDZ format: rendered via SceneKit SCNView */
+ USDZ,
+
+ /** GLB / GLTF format: shows placeholder icon (QLThumbnailGenerator not available in Kotlin/Native cinterop) */
+ GLB_THUMBNAIL,
+
+ /** Unknown or unsupported format: shows placeholder icon */
+ PLACEHOLDER
+}
+
+/**
+ * Android thumbnail loading state machine.
+ */
+enum class ThumbnailState {
+ /** Model file exists; async load has started */
+ Loading,
+
+ /** Model loaded successfully */
+ Loaded,
+
+ /** File missing or load failed */
+ Error
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.kt
new file mode 100644
index 0000000..971b720
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.kt
@@ -0,0 +1,14 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/**
+ * Platform-specific composable to display a captured photo from a file path or content URI.
+ * Android: loads and renders the image asynchronously.
+ * iOS: shows a placeholder icon.
+ *
+ * [uri] may be a content:// URI string (MediaStore) or an absolute file path.
+ */
+@Composable
+expect fun PhotoThumbnail(uri: String?, modifier: Modifier = Modifier)
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ARScreen.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ARScreen.kt
index a00cac9..e2397c0 100644
--- a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ARScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ARScreen.kt
@@ -1,34 +1,54 @@
package com.trendhive.arsample.presentation.ui.screens
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.ViewInAr
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.shape.RoundedCornerShape
import com.trendhive.arsample.ar.PlatformARView
import com.trendhive.arsample.domain.model.ARObject
import com.trendhive.arsample.domain.model.PlacedObject
import com.trendhive.arsample.presentation.ui.components.ArrowBackIcon
+import com.trendhive.arsample.presentation.ui.components.CameraControlsBar
import com.trendhive.arsample.presentation.ui.components.ImportDialog
import com.trendhive.arsample.presentation.ui.components.MenuIcon
+import com.trendhive.arsample.presentation.ui.components.RecordingBorderGlow
+import com.trendhive.arsample.presentation.ui.components.RecordingTimerDisplay
import com.trendhive.arsample.presentation.platform.rememberModelFilePicker
import com.trendhive.arsample.presentation.viewmodel.ARUiState
+import com.trendhive.arsample.presentation.viewmodel.RecordingState
import org.jetbrains.compose.resources.stringResource
import arsample.composeapp.generated.resources.Res
import arsample.composeapp.generated.resources.*
@@ -48,6 +68,12 @@ fun ARScreen(
onDragStart: (objectId: String, touchX: Float, touchY: Float) -> Unit = { _, _, _ -> },
onDragUpdate: (newX: Float, newY: Float, newZ: Float, screenX: Float, screenY: Float, isOverTrash: Boolean) -> Unit = { _, _, _, _, _, _ -> },
onDragEnd: () -> Unit = {},
+ onToggleRecording: () -> Unit = {},
+ onClearRecordingState: () -> Unit = {},
+ onCapturePhoto: () -> Unit = {},
+ onOpenGallery: () -> Unit = {},
+ onPhotoCaptured: ((ByteArray?) -> Unit)? = null,
+ onClearShutterFlash: () -> Unit = {},
modifier: Modifier = Modifier
) {
// CRITICAL FIX: Use rememberUpdatedState to ensure callbacks always capture latest state
@@ -107,9 +133,25 @@ fun ARScreen(
.padding(paddingValues)
) {
val density = LocalDensity.current
- val trashZoneHeight = 80.dp
+ // TrashZone dimensions must match the actual component:
+ // modifier = modifier.padding(16.dp).size(width = 100.dp, height = 90.dp)
+ val trashZoneWidth = 100.dp
+ val trashZoneHeight = 90.dp
+ val trashZonePadding = 16.dp
+ val trashZoneWidthPx = with(density) { trashZoneWidth.toPx() }
val trashZoneHeightPx = with(density) { trashZoneHeight.toPx() }
+ val trashZonePaddingPx = with(density) { trashZonePadding.toPx() }
val screenHeightPx = with(density) { maxHeight.toPx() }
+ val screenWidthPx = with(density) { maxWidth.toPx() }
+
+ // Trash zone is at bottom-right (Alignment.BottomEnd):
+ // - X starts at: screenWidth - padding - width
+ // - Y starts at: screenHeight - padding - height
+ fun isOverTrashZone(screenX: Float, screenY: Float): Boolean {
+ val trashLeft = screenWidthPx - trashZoneWidthPx - trashZonePaddingPx
+ val trashTop = screenHeightPx - trashZoneHeightPx - trashZonePaddingPx
+ return screenX >= trashLeft && screenY >= trashTop
+ }
// Platform-specific AR View
// CRITICAL FIX: Use currentUiState (rememberUpdatedState) instead of uiState
@@ -134,36 +176,50 @@ fun ARScreen(
},
onDragMove = { objectId, screenX, screenY ->
if (draggingObjectId != objectId) return@PlatformARView
- val isOverTrash = screenY > (screenHeightPx - trashZoneHeightPx)
+ val isOverTrash = isOverTrashZone(screenX, screenY)
isOverTrashZone = isOverTrash
// Find the current object to get its position
val currentObj = currentUiState.placedObjects.find { it.objectId == objectId }
- if (currentObj != null) {
- val progress = if (isOverTrash) {
- ((screenY - (screenHeightPx - trashZoneHeightPx)) / trashZoneHeightPx).coerceIn(0f, 1f)
- } else 0f
-
- // Call ViewModel drag update
- onDragUpdate(
- currentObj.position.x,
- currentObj.position.y,
- currentObj.position.z,
- screenX,
- screenY,
- isOverTrash
- )
- }
+ val position = currentObj?.position
+
+ // Call ViewModel drag update with position (use 0,0,0 if not found)
+ onDragUpdate(
+ position?.x ?: 0f,
+ position?.y ?: 0f,
+ position?.z ?: 0f,
+ screenX,
+ screenY,
+ isOverTrash
+ )
},
- onDragEnd = { objectId, _, screenY ->
+ onDragEnd = { objectId, screenX, screenY ->
if (draggingObjectId == objectId) {
+ // FIX: Perform final trash zone check at drag end position
+ // This ensures deletion works even if last onDragMove was missed
+ val finalIsOverTrash = isOverTrashZone(screenX, screenY)
+ if (finalIsOverTrash) {
+ // Update ViewModel state one last time before ending drag
+ val currentObj = currentUiState.placedObjects.find { it.objectId == objectId }
+ val position = currentObj?.position
+ onDragUpdate(
+ position?.x ?: 0f,
+ position?.y ?: 0f,
+ position?.z ?: 0f,
+ screenX,
+ screenY,
+ true // Force isOverTrash = true
+ )
+ }
// Call ViewModel drag end (which handles trash zone logic)
onDragEnd()
}
isDragging = false
draggingObjectId = null
isOverTrashZone = false
- }
+ },
+ captureRequest = uiState.captureRequest,
+ onCaptureComplete = onPhotoCaptured
)
// Loading indicator
@@ -201,38 +257,57 @@ fun ARScreen(
)
}
- // Selected object controls
- uiState.selectedObjectId?.let { selectedId ->
- Surface(
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .padding(16.dp),
- shape = MaterialTheme.shapes.medium,
- color = MaterialTheme.colorScheme.surfaceVariant
- ) {
- Row(
- modifier = Modifier.padding(16.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically
+ // Selected object indicator - positioned at top-left below app bar
+ AnimatedVisibility(
+ visible = uiState.selectedObjectId != null,
+ enter = fadeIn(tween(200)) + slideInVertically { -it },
+ exit = fadeOut(tween(200)) + slideOutVertically { -it },
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(start = 12.dp, top = 8.dp)
+ ) {
+ uiState.selectedObjectId?.let { selectedId ->
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = Color.Black.copy(alpha = 0.7f),
+ tonalElevation = 4.dp
) {
- Text(
- text = selectedObject?.name ?: "${stringResource(Res.string.selected)}: ${selectedId.take(8)}…",
- modifier = Modifier.weight(1f)
- )
- // Cancel selection button
- IconButton(
- onClick = { onSelectObject(null) }
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
- imageVector = Icons.Default.Close,
- contentDescription = stringResource(Res.string.cancel),
- tint = MaterialTheme.colorScheme.error
+ imageVector = Icons.Default.ViewInAr,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = Color.White
)
- }
- Button(
- onClick = { showObjectList = true },
- ) {
- Text(stringResource(Res.string.objects))
+ Column {
+ Text(
+ text = selectedObject?.name ?: selectedId.take(8),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.White
+ )
+ Text(
+ text = stringResource(Res.string.long_press_to_place),
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White.copy(alpha = 0.7f)
+ )
+ }
+ // Cancel button
+ IconButton(
+ onClick = { onSelectObject(null) },
+ modifier = Modifier.size(28.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(Res.string.cancel),
+ modifier = Modifier.size(18.dp),
+ tint = Color.White.copy(alpha = 0.8f)
+ )
+ }
}
}
}
@@ -241,9 +316,100 @@ fun ARScreen(
TrashZone(
isVisible = isDragging,
isHovered = isOverTrashZone,
- modifier = Modifier.align(Alignment.BottomCenter)
+ modifier = Modifier.align(Alignment.BottomEnd)
)
+ // Recording border glow effect
+ RecordingBorderGlow(isRecording = uiState.isRecording)
+
+ // Recording Timer Display (top center when recording)
+ AnimatedVisibility(
+ visible = uiState.isRecording,
+ enter = fadeIn(tween(300)),
+ exit = fadeOut(tween(300)),
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 16.dp)
+ ) {
+ RecordingTimerDisplay(
+ durationSeconds = uiState.recordingDurationSeconds
+ )
+ }
+
+ // Shutter flash overlay - white flash that fades when photo is captured
+ val shutterAlpha by animateFloatAsState(
+ targetValue = if (uiState.showShutterFlash) 1f else 0f,
+ animationSpec = tween(durationMillis = 100),
+ label = "shutter_flash"
+ )
+ LaunchedEffect(uiState.showShutterFlash) {
+ if (uiState.showShutterFlash) {
+ kotlinx.coroutines.delay(180)
+ onClearShutterFlash()
+ }
+ }
+ if (shutterAlpha > 0f) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White.copy(alpha = shutterAlpha))
+ )
+ }
+
+ // Camera Controls Bar (bottom) — hidden while dragging so it doesn't
+ // intercept the touch events that the ARSceneView needs to detect
+ // the trash-zone drop.
+ AnimatedVisibility(
+ visible = !isDragging,
+ enter = fadeIn(tween(150)),
+ exit = fadeOut(tween(150)),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 16.dp)
+ ) {
+ CameraControlsBar(
+ isRecording = uiState.isRecording,
+ onCapturePhoto = onCapturePhoto,
+ onToggleRecording = onToggleRecording,
+ onOpenGallery = onOpenGallery,
+ lastPhotoUri = uiState.lastCapturedPhotoPath,
+ )
+ }
+
+ // Recording state snackbar
+ val recordingState = uiState.recordingState
+ if (recordingState is RecordingState.Success || recordingState is RecordingState.Error) {
+ LaunchedEffect(recordingState) {
+ kotlinx.coroutines.delay(3000)
+ onClearRecordingState()
+ }
+
+ Snackbar(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ .padding(bottom = 120.dp),
+ containerColor = when (recordingState) {
+ is RecordingState.Success -> MaterialTheme.colorScheme.primaryContainer
+ is RecordingState.Error -> MaterialTheme.colorScheme.errorContainer
+ else -> MaterialTheme.colorScheme.surface
+ }
+ ) {
+ Text(
+ text = when (recordingState) {
+ is RecordingState.Success -> recordingState.message
+ is RecordingState.Error -> recordingState.message
+ else -> ""
+ },
+ color = when (recordingState) {
+ is RecordingState.Success -> MaterialTheme.colorScheme.onPrimaryContainer
+ is RecordingState.Error -> MaterialTheme.colorScheme.onErrorContainer
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ )
+ }
+ }
+
}
// Object selection sheet
@@ -346,36 +512,55 @@ fun TrashZone(
) {
Box(
modifier = modifier
- .fillMaxWidth()
- .height(80.dp)
+ .padding(16.dp)
+ .size(width = 100.dp, height = 90.dp)
+ .shadow(
+ elevation = if (isHovered) 8.dp else 4.dp,
+ shape = RoundedCornerShape(16.dp),
+ ambientColor = MaterialTheme.colorScheme.error.copy(alpha = 0.3f),
+ spotColor = MaterialTheme.colorScheme.error.copy(alpha = 0.3f)
+ )
.background(
- if (isHovered)
- MaterialTheme.colorScheme.error.copy(alpha = 0.9f)
+ color = if (isHovered)
+ MaterialTheme.colorScheme.error.copy(alpha = 0.85f)
+ else
+ MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
+ shape = RoundedCornerShape(16.dp)
+ )
+ .border(
+ width = 1.dp,
+ color = if (isHovered)
+ MaterialTheme.colorScheme.onError.copy(alpha = 0.3f)
else
- MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f)
+ MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.2f),
+ shape = RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Delete,
- contentDescription = "Delete",
+ contentDescription = stringResource(Res.string.delete),
tint = if (isHovered)
MaterialTheme.colorScheme.onError
else
MaterialTheme.colorScheme.onErrorContainer,
- modifier = Modifier.size(if (isHovered) 32.dp else 24.dp)
+ modifier = Modifier.size(if (isHovered) 32.dp else 28.dp)
)
- Spacer(modifier = Modifier.width(8.dp))
+ Spacer(modifier = Modifier.height(6.dp))
Text(
- text = if (isHovered) "Release to Delete" else "Drag here to delete",
+ text = stringResource(if (isHovered) Res.string.release_to_delete else Res.string.drag_to_delete),
+ style = MaterialTheme.typography.labelSmall,
color = if (isHovered)
MaterialTheme.colorScheme.onError
else
- MaterialTheme.colorScheme.onErrorContainer
+ MaterialTheme.colorScheme.onErrorContainer,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
)
}
}
@@ -466,13 +651,15 @@ private fun ObjectListItem(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Icon(
- imageVector = Icons.Default.ViewInAr,
- contentDescription = null,
- modifier = Modifier.size(40.dp),
- tint = MaterialTheme.colorScheme.primary
+ // 3D model preview thumbnail
+ ObjectThumbnail(
+ modelUri = arObject.modelUri,
+ thumbnailUri = arObject.thumbnailUri,
+ modelType = arObject.modelType,
+ isSelected = isSelected,
+ modifier = Modifier.size(48.dp)
)
- Spacer(modifier = Modifier.width(16.dp))
+ Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = arObject.name,
@@ -495,6 +682,39 @@ private fun ObjectListItem(
}
}
+/**
+ * Displays a thumbnail for a 3D object.
+ * Shows a 3D model preview when modelUri is available,
+ * otherwise displays a placeholder icon.
+ */
+@Composable
+private fun ObjectThumbnail(
+ modelUri: String,
+ thumbnailUri: String?,
+ modelType: com.trendhive.arsample.domain.model.ModelType,
+ isSelected: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val backgroundColor = if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+
+ Box(
+ modifier = modifier
+ .background(backgroundColor, RoundedCornerShape(8.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ // Use 3D model preview for interactive thumbnail
+ com.trendhive.arsample.presentation.ui.components.ModelPreviewThumbnail(
+ modelPath = modelUri,
+ modifier = Modifier.fillMaxSize(),
+ autoRotate = true
+ )
+ }
+}
+
@Composable
fun PlacedObjectsList(
placedObjects: List,
@@ -577,3 +797,83 @@ private fun PlacedObjectListItem(
}
}
}
+
+/**
+ * Video recording button that toggles between start and stop states.
+ */
+@Composable
+fun VideoRecordButton(
+ isRecording: Boolean,
+ onToggleRecording: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ FloatingActionButton(
+ onClick = onToggleRecording,
+ modifier = modifier.size(56.dp),
+ containerColor = if (isRecording) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ },
+ contentColor = if (isRecording) {
+ MaterialTheme.colorScheme.onError
+ } else {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ }
+ ) {
+ Icon(
+ imageVector = if (isRecording) Icons.Default.Stop else Icons.Default.Videocam,
+ contentDescription = if (isRecording) "Stop recording" else "Start recording",
+ modifier = Modifier.size(24.dp)
+ )
+ }
+}
+
+/**
+ * Recording indicator - pulsing red dot shown when recording is active.
+ */
+@Composable
+fun RecordingIndicator(
+ modifier: Modifier = Modifier
+) {
+ val infiniteTransition = rememberInfiniteTransition(label = "recording_pulse")
+ val alpha by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 0.3f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 800),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "pulse_alpha"
+ )
+
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f),
+ tonalElevation = 4.dp
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Pulsing red dot
+ Box(
+ modifier = Modifier
+ .size(12.dp)
+ .alpha(alpha)
+ .background(
+ color = Color.Red,
+ shape = CircleShape
+ )
+ )
+ Text(
+ text = "REC",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/GalleryScreen.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/GalleryScreen.kt
new file mode 100644
index 0000000..d78ccd1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/GalleryScreen.kt
@@ -0,0 +1,605 @@
+package com.trendhive.arsample.presentation.ui.screens
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.PhotoLibrary
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.trendhive.arsample.domain.model.CapturedPhoto
+import com.trendhive.arsample.domain.model.CapturedVideo
+import com.trendhive.arsample.domain.model.MediaItem
+import com.trendhive.arsample.presentation.ui.components.ArrowBackIcon
+import com.trendhive.arsample.presentation.ui.components.PlayArrowIcon
+import com.trendhive.arsample.presentation.viewmodel.GalleryFilter
+import com.trendhive.arsample.presentation.viewmodel.GalleryUiState
+
+/**
+ * Gallery screen for viewing captured photos and videos.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GalleryScreen(
+ uiState: GalleryUiState,
+ onPhotoClick: (CapturedPhoto) -> Unit,
+ onVideoClick: (CapturedVideo) -> Unit,
+ onDeletePhoto: (String) -> Unit,
+ onDeleteVideo: (String) -> Unit,
+ onFilterChange: (GalleryFilter) -> Unit,
+ onClosePreview: () -> Unit,
+ onNavigateBack: () -> Unit,
+ onClearError: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+ var deleteConfirmation by remember { mutableStateOf(null) }
+
+ // Show error in snackbar
+ LaunchedEffect(uiState.error) {
+ uiState.error?.let { error ->
+ snackbarHostState.showSnackbar(error)
+ onClearError()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Gallery") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(ArrowBackIcon, contentDescription = "Back")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ modifier = modifier
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Filter chips
+ FilterChipRow(
+ currentFilter = uiState.filter,
+ photoCount = uiState.photoCount,
+ videoCount = uiState.videoCount,
+ onFilterChange = onFilterChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ // Content
+ Box(modifier = Modifier.fillMaxSize()) {
+ when {
+ uiState.isLoading && uiState.filteredMedia.isEmpty() -> {
+ LoadingState(modifier = Modifier.align(Alignment.Center))
+ }
+ uiState.isEmpty -> {
+ EmptyState(
+ filter = uiState.filter,
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ else -> {
+ MediaGrid(
+ items = uiState.filteredMedia,
+ onItemClick = { item ->
+ when (item) {
+ is MediaItem.Photo -> onPhotoClick(item.photo)
+ is MediaItem.Video -> onVideoClick(item.video)
+ }
+ },
+ onDeleteClick = { item ->
+ deleteConfirmation = item
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+
+ // Loading overlay for delete operations
+ if (uiState.isLoading && uiState.filteredMedia.isNotEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.3f)),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(color = Color.White)
+ }
+ }
+ }
+ }
+
+ // Full screen preview
+ AnimatedVisibility(
+ visible = uiState.isPreviewVisible && uiState.selectedMedia != null,
+ enter = fadeIn() + scaleIn(),
+ exit = fadeOut() + scaleOut()
+ ) {
+ uiState.selectedMedia?.let { media ->
+ FullScreenPreview(
+ media = media,
+ onClose = onClosePreview,
+ onDelete = {
+ when (media) {
+ is MediaItem.Photo -> onDeletePhoto(media.id)
+ is MediaItem.Video -> onDeleteVideo(media.id)
+ }
+ }
+ )
+ }
+ }
+
+ // Delete confirmation dialog
+ deleteConfirmation?.let { media ->
+ DeleteConfirmationDialog(
+ mediaType = when (media) {
+ is MediaItem.Photo -> "photo"
+ is MediaItem.Video -> "video"
+ },
+ onConfirm = {
+ when (media) {
+ is MediaItem.Photo -> onDeletePhoto(media.id)
+ is MediaItem.Video -> onDeleteVideo(media.id)
+ }
+ deleteConfirmation = null
+ },
+ onDismiss = { deleteConfirmation = null }
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun FilterChipRow(
+ currentFilter: GalleryFilter,
+ photoCount: Int,
+ videoCount: Int,
+ onFilterChange: (GalleryFilter) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ FilterChip(
+ selected = currentFilter == GalleryFilter.ALL,
+ onClick = { onFilterChange(GalleryFilter.ALL) },
+ label = { Text("All (${photoCount + videoCount})") },
+ leadingIcon = {
+ Icon(
+ Icons.Default.PhotoLibrary,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+
+ FilterChip(
+ selected = currentFilter == GalleryFilter.PHOTOS,
+ onClick = { onFilterChange(GalleryFilter.PHOTOS) },
+ label = { Text("Photos ($photoCount)") },
+ leadingIcon = {
+ Icon(
+ Icons.Default.Image,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+
+ FilterChip(
+ selected = currentFilter == GalleryFilter.VIDEOS,
+ onClick = { onFilterChange(GalleryFilter.VIDEOS) },
+ label = { Text("Videos ($videoCount)") },
+ leadingIcon = {
+ Icon(
+ Icons.Default.Videocam,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+ }
+}
+
+@Composable
+private fun MediaGrid(
+ items: List,
+ onItemClick: (MediaItem) -> Unit,
+ onDeleteClick: (MediaItem) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(3),
+ modifier = modifier,
+ contentPadding = PaddingValues(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ // Prefix keys by type — photo and video MediaStore IDs are independent
+ // numeric sequences and can collide when displayed together.
+ items(items, key = { item ->
+ when (item) {
+ is MediaItem.Photo -> "photo_${item.id}"
+ is MediaItem.Video -> "video_${item.id}"
+ }
+ }) { item ->
+ MediaGridItem(
+ item = item,
+ onClick = { onItemClick(item) },
+ onDeleteClick = { onDeleteClick(item) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun MediaGridItem(
+ item: MediaItem,
+ onClick: () -> Unit,
+ onDeleteClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(8.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Thumbnail placeholder (platform-specific image loading would go here)
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = when (item) {
+ is MediaItem.Photo -> Icons.Default.Image
+ is MediaItem.Video -> Icons.Default.Videocam
+ },
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ modifier = Modifier.size(32.dp)
+ )
+ }
+
+ // Video duration badge
+ if (item is MediaItem.Video) {
+ VideoDurationBadge(
+ durationMs = item.video.durationMs,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(4.dp)
+ )
+ }
+
+ // Delete button (top-right corner)
+ IconButton(
+ onClick = onDeleteClick,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .size(32.dp)
+ .padding(2.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .background(
+ Color.Black.copy(alpha = 0.5f),
+ CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = Color.White,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun VideoDurationBadge(
+ durationMs: Long,
+ modifier: Modifier = Modifier
+) {
+ val seconds = (durationMs / 1000).toInt()
+ val minutes = seconds / 60
+ val remainingSeconds = seconds % 60
+ val formattedDuration = if (minutes > 0) {
+ "$minutes:${remainingSeconds.toString().padStart(2, '0')}"
+ } else {
+ "0:${remainingSeconds.toString().padStart(2, '0')}"
+ }
+
+ Row(
+ modifier = modifier
+ .background(
+ Color.Black.copy(alpha = 0.7f),
+ RoundedCornerShape(4.dp)
+ )
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ PlayArrowIcon,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(12.dp)
+ )
+ Spacer(modifier = Modifier.width(2.dp))
+ Text(
+ text = formattedDuration,
+ color = Color.White,
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+}
+
+@Composable
+private fun EmptyState(
+ filter: GalleryFilter,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Default.PhotoLibrary,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ modifier = Modifier.size(64.dp)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = when (filter) {
+ GalleryFilter.ALL -> "No media yet"
+ GalleryFilter.PHOTOS -> "No photos yet"
+ GalleryFilter.VIDEOS -> "No videos yet"
+ },
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "Capture photos and videos from your AR scenes to see them here.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@Composable
+private fun LoadingState(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Loading media...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun FullScreenPreview(
+ media: MediaItem,
+ onClose: () -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) {
+ // Media display (placeholder)
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = when (media) {
+ is MediaItem.Photo -> Icons.Default.Image
+ is MediaItem.Video -> Icons.Default.Videocam
+ },
+ contentDescription = null,
+ tint = Color.White.copy(alpha = 0.6f),
+ modifier = Modifier.size(80.dp)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = when (media) {
+ is MediaItem.Photo -> "Photo Preview"
+ is MediaItem.Video -> "Video Preview"
+ },
+ style = MaterialTheme.typography.titleMedium,
+ color = Color.White
+ )
+
+ // Video play button
+ if (media is MediaItem.Video) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Box(
+ modifier = Modifier
+ .size(64.dp)
+ .background(Color.White.copy(alpha = 0.2f), CircleShape)
+ .clickable { /* TODO: Play video */ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ PlayArrowIcon,
+ contentDescription = "Play",
+ tint = Color.White,
+ modifier = Modifier.size(40.dp)
+ )
+ }
+ }
+ }
+ }
+
+ // Top bar with close and delete buttons
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color.Black.copy(alpha = 0.5f), CircleShape)
+ ) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Close",
+ tint = Color.White
+ )
+ }
+
+ IconButton(
+ onClick = onDelete,
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color.Black.copy(alpha = 0.5f), CircleShape)
+ ) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = Color.White
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DeleteConfirmationDialog(
+ mediaType: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Delete $mediaType?") },
+ text = { Text("This action cannot be undone.") },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("Delete")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectGalleryScreen.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectGalleryScreen.kt
new file mode 100644
index 0000000..3c82ef0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectGalleryScreen.kt
@@ -0,0 +1,497 @@
+package com.trendhive.arsample.presentation.ui.screens
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ViewInAr
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.trendhive.arsample.domain.model.ARObject
+import com.trendhive.arsample.domain.model.ModelType
+import com.trendhive.arsample.presentation.ui.components.AddIcon
+import com.trendhive.arsample.presentation.ui.components.ArrowBackIcon
+import com.trendhive.arsample.presentation.ui.components.DeleteIcon
+import com.trendhive.arsample.presentation.ui.components.ImportDialog
+import com.trendhive.arsample.presentation.ui.components.ModelPreviewEngineProvider
+import com.trendhive.arsample.presentation.ui.components.ModelPreviewThumbnail
+import com.trendhive.arsample.presentation.platform.rememberModelFilePicker
+import com.trendhive.arsample.presentation.viewmodel.ObjectListUiState
+import org.jetbrains.compose.resources.stringResource
+import arsample.composeapp.generated.resources.Res
+import arsample.composeapp.generated.resources.*
+
+/**
+ * Object Gallery Screen - Displays 3D objects in a grid layout for browsing and management.
+ *
+ * @param uiState The current UI state containing objects and loading/error states
+ * @param onObjectClick Called when a user taps an object to select it for AR placement
+ * @param onObjectDelete Called when a user requests to delete an object
+ * @param onImportClick Called when a user imports a new 3D model
+ * @param onNavigateBack Called when user wants to go back
+ * @param onNavigateToAR Called when user wants to open AR scene
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ObjectGalleryScreen(
+ uiState: ObjectListUiState,
+ onObjectClick: (ARObject) -> Unit,
+ onObjectDelete: (String) -> Unit,
+ onImportClick: (uri: String, name: String, type: ModelType) -> Unit,
+ onNavigateBack: () -> Unit,
+ onNavigateToAR: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var showImportDialog by remember { mutableStateOf(false) }
+ var pendingImport by remember { mutableStateOf?>(null) }
+ var searchQuery by remember { mutableStateOf("") }
+
+ val launchPicker = rememberModelFilePicker { uri ->
+ val (name, type) = pendingImport ?: return@rememberModelFilePicker
+ pendingImport = null
+ onImportClick(uri, name, type)
+ }
+
+ // Filter objects by search query
+ val filteredObjects = remember(uiState.objects, searchQuery) {
+ if (searchQuery.isBlank()) {
+ uiState.objects
+ } else {
+ uiState.objects.filter { obj ->
+ obj.name.contains(searchQuery, ignoreCase = true) ||
+ obj.modelType.name.contains(searchQuery, ignoreCase = true)
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(Res.string.object_gallery)) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(ArrowBackIcon, contentDescription = stringResource(Res.string.back))
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ )
+ },
+ floatingActionButton = {
+ Column(
+ horizontalAlignment = Alignment.End,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // AR FAB
+ FloatingActionButton(
+ onClick = onNavigateToAR,
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ) {
+ Icon(
+ Icons.Default.ViewInAr,
+ contentDescription = stringResource(Res.string.start_ar)
+ )
+ }
+ // Import FAB
+ FloatingActionButton(
+ onClick = { showImportDialog = true }
+ ) {
+ Icon(AddIcon, contentDescription = stringResource(Res.string.import))
+ }
+ }
+ },
+ modifier = modifier
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Search bar
+ SearchBar(
+ query = searchQuery,
+ onQueryChange = { searchQuery = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ when {
+ uiState.isLoading -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ uiState.objects.isEmpty() -> {
+ EmptyGalleryState(
+ onImportClick = { showImportDialog = true },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ filteredObjects.isEmpty() -> {
+ NoSearchResultsState(
+ query = searchQuery,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ else -> {
+ // Wrap only the grid in ModelPreviewEngineProvider so all thumbnail
+ // composables share a single Filament Engine instance. Narrowing scope
+ // here avoids wrapping unrelated UI (dialogs, snackbars) and makes
+ // engine lifecycle identical to the grid's lifecycle.
+ ModelPreviewEngineProvider {
+ ObjectGalleryGrid(
+ objects = filteredObjects,
+ onObjectClick = onObjectClick,
+ onObjectDelete = onObjectDelete,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+
+ // Error snackbar
+ uiState.error?.let { error ->
+ Snackbar(
+ modifier = Modifier.padding(16.dp),
+ action = {
+ TextButton(onClick = { /* clear error handled by parent */ }) {
+ Text(stringResource(Res.string.dismiss))
+ }
+ }
+ ) {
+ Text(error)
+ }
+ }
+ }
+ }
+
+ // Import Dialog
+ if (showImportDialog) {
+ ImportDialog(
+ onDismiss = { showImportDialog = false },
+ onConfirm = { name, type ->
+ pendingImport = name to type
+ launchPicker()
+ showImportDialog = false
+ }
+ )
+ }
+}
+
+/**
+ * Search bar component for filtering objects
+ */
+@Composable
+private fun SearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ placeholder = { Text(stringResource(Res.string.search_objects)) },
+ singleLine = true,
+ shape = RoundedCornerShape(24.dp),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { /* Already filtering on change */ }),
+ modifier = modifier
+ )
+}
+
+/**
+ * Grid layout displaying object cards
+ */
+@Composable
+private fun ObjectGalleryGrid(
+ objects: List,
+ onObjectClick: (ARObject) -> Unit,
+ onObjectDelete: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(2),
+ contentPadding = PaddingValues(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = modifier
+ ) {
+ items(
+ items = objects,
+ key = { it.id }
+ ) { arObject ->
+ ObjectGalleryCard(
+ arObject = arObject,
+ onClick = { onObjectClick(arObject) },
+ onDelete = { onObjectDelete(arObject.id) },
+ modifier = Modifier.animateItem()
+ )
+ }
+ }
+}
+
+/**
+ * Individual card displaying a 3D object with preview and info
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ObjectGalleryCard(
+ arObject: ARObject,
+ onClick: () -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ Card(
+ onClick = onClick,
+ modifier = modifier.aspectRatio(0.85f),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = 4.dp,
+ pressedElevation = 8.dp
+ )
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Preview area (2/3 of card) - 3D model preview
+ Box(
+ modifier = Modifier
+ .weight(2f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ ModelPreviewThumbnail(
+ modelPath = arObject.modelUri,
+ modifier = Modifier.fillMaxSize(),
+ autoRotate = true
+ )
+ }
+
+ // Info area (1/3 of card)
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ // Object name
+ Text(
+ text = arObject.name,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Created date
+ Text(
+ text = formatDate(arObject.createdAt),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Model type badge (top-right corner)
+ ModelTypeBadge(
+ modelType = arObject.modelType,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ )
+
+ // Delete button (bottom-right corner)
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(4.dp)
+ ) {
+ IconButton(
+ onClick = { showMenu = true },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ DeleteIcon,
+ contentDescription = stringResource(Res.string.delete),
+ tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+
+ // Confirmation dropdown
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.delete)) },
+ onClick = {
+ showMenu = false
+ onDelete()
+ },
+ leadingIcon = {
+ Icon(
+ DeleteIcon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Badge showing the model file type (GLB, USDZ, etc.)
+ */
+@Composable
+private fun ModelTypeBadge(
+ modelType: ModelType,
+ modifier: Modifier = Modifier
+) {
+ val backgroundColor = when (modelType) {
+ ModelType.GLB, ModelType.GLTF -> Color(0xFF4CAF50) // Green for glTF family
+ ModelType.USDZ -> Color(0xFF2196F3) // Blue for Apple format
+ ModelType.OBJ -> Color(0xFFFF9800) // Orange for OBJ
+ }
+
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(4.dp),
+ color = backgroundColor
+ ) {
+ Text(
+ text = modelType.name,
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+}
+
+/**
+ * Empty state when no objects have been imported yet
+ */
+@Composable
+private fun EmptyGalleryState(
+ onImportClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.ViewInAr,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(Res.string.no_objects_yet),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(Res.string.tap_to_import),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(onClick = onImportClick) {
+ Icon(AddIcon, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(Res.string.import_first_object))
+ }
+ }
+}
+
+/**
+ * State shown when search yields no results
+ */
+@Composable
+private fun NoSearchResultsState(
+ query: String,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = stringResource(Res.string.no_results_found),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(Res.string.no_results_for_query, query),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+/**
+ * Format timestamp to readable date string
+ */
+private fun formatDate(timestamp: Long): String {
+ // Simple date formatting - in production, use platform-specific formatting
+ val seconds = timestamp / 1000
+ val minutes = seconds / 60
+ val hours = minutes / 60
+ val days = hours / 24
+
+ return when {
+ days > 0 -> "${days}d ago"
+ hours > 0 -> "${hours}h ago"
+ minutes > 0 -> "${minutes}m ago"
+ else -> "Just now"
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectListScreen.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectListScreen.kt
index 2c445f6..d6228c0 100644
--- a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectListScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectListScreen.kt
@@ -1,14 +1,19 @@
package com.trendhive.arsample.presentation.ui.screens
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ViewInAr
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.trendhive.arsample.domain.model.ARObject
+import com.trendhive.arsample.domain.model.ModelType
import com.trendhive.arsample.presentation.ui.components.AddIcon
import com.trendhive.arsample.presentation.ui.components.DeleteIcon
import com.trendhive.arsample.presentation.ui.components.ImportDialog
@@ -155,6 +160,13 @@ fun ObjectListItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
+ // Thumbnail or icon
+ ObjectThumbnailItem(
+ thumbnailUri = obj.thumbnailUri,
+ modelType = obj.modelType,
+ modifier = Modifier.size(48.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = obj.name,
@@ -176,3 +188,33 @@ fun ObjectListItem(
}
}
}
+
+/**
+ * Displays a thumbnail for a 3D object in the object list.
+ * Shows a custom thumbnail image if available, otherwise displays
+ * a model-type specific icon.
+ */
+@Composable
+private fun ObjectThumbnailItem(
+ thumbnailUri: String?,
+ modelType: ModelType,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant,
+ RoundedCornerShape(8.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ // For now, use placeholder icons based on model type
+ // TODO: When thumbnailUri is available, load actual thumbnail image
+ Icon(
+ imageVector = Icons.Default.ViewInAr,
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/viewmodel/ARViewModel.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/viewmodel/ARViewModel.kt
index 5b651ce..30efcce 100644
--- a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/viewmodel/ARViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/viewmodel/ARViewModel.kt
@@ -8,8 +8,11 @@ import com.trendhive.arsample.domain.model.ScreenPosition
import com.trendhive.arsample.domain.model.TrashZoneState
import com.trendhive.arsample.domain.model.Vector3
import com.trendhive.arsample.domain.model.currentTimeMillis
+import com.trendhive.arsample.application.usecase.CapturePhotoUseCase
+import com.trendhive.arsample.application.usecase.GetPhotosUseCase
import com.trendhive.arsample.application.usecase.MoveObjectUseCase
import com.trendhive.arsample.application.usecase.PlaceObjectInSceneUseCase
+import com.trendhive.arsample.application.usecase.RecordVideoUseCase
import com.trendhive.arsample.application.usecase.RemoveObjectFromSceneUseCase
import com.trendhive.arsample.application.usecase.GetSceneUseCase
import com.trendhive.arsample.application.usecase.SaveSceneUseCase
@@ -19,9 +22,33 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+/**
+ * Sealed class representing photo capture states.
+ */
+sealed class CaptureState {
+ object Idle : CaptureState()
+ object Capturing : CaptureState()
+ data class Success(val message: String) : CaptureState()
+ data class Error(val message: String) : CaptureState()
+}
+
+/**
+ * Sealed class representing video recording states.
+ */
+sealed class RecordingState {
+ object Idle : RecordingState()
+ object Recording : RecordingState()
+ object Stopping : RecordingState()
+ data class Success(val message: String) : RecordingState()
+ data class Error(val message: String) : RecordingState()
+}
+
data class ARUiState(
val currentScene: ARScene? = null,
val placedObjects: List = emptyList(),
@@ -29,7 +56,20 @@ data class ARUiState(
val error: String? = null,
val selectedObjectId: String? = null,
val dragState: DragState = DragState.Idle,
- val trashZoneState: TrashZoneState = TrashZoneState.Hidden
+ val trashZoneState: TrashZoneState = TrashZoneState.Hidden,
+ val captureState: CaptureState = CaptureState.Idle,
+ val captureRequest: Boolean = false,
+ val recordingState: RecordingState = RecordingState.Idle,
+ val isRecording: Boolean = false,
+ val recordingDurationSeconds: Long = 0L,
+ // Raw bytes of the most recently captured photo, used for the thumbnail in the AR screen.
+ // Null when no photo has been taken yet in this session.
+ val lastCapturedPhotoData: ByteArray? = null,
+ // File path / content URI of the most recent photo — survives process restart because
+ // it is re-loaded from the MediaRepository when the scene is loaded.
+ val lastCapturedPhotoPath: String? = null,
+ // Momentarily true right after a photo is captured to trigger the shutter flash animation.
+ val showShutterFlash: Boolean = false
)
class ARViewModel(
@@ -38,7 +78,10 @@ class ARViewModel(
private val getSceneUseCase: GetSceneUseCase,
private val saveSceneUseCase: SaveSceneUseCase,
private val sceneRepository: ARSceneRepository,
- private val moveObjectUseCase: MoveObjectUseCase
+ private val moveObjectUseCase: MoveObjectUseCase,
+ private val capturePhotoUseCase: CapturePhotoUseCase? = null,
+ private val recordVideoUseCase: RecordVideoUseCase? = null,
+ private val getPhotosUseCase: GetPhotosUseCase? = null
) : androidx.lifecycle.ViewModel() {
companion object {
@@ -50,6 +93,9 @@ class ARViewModel(
val uiState: StateFlow = _uiState.asStateFlow()
private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ // Timer job for recording duration
+ private var recordingTimerJob: Job? = null
init {
loadScene()
@@ -60,10 +106,19 @@ class ARViewModel(
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val scene = sceneRepository.getOrCreateDefaultScene()
+
+ // Load the most recent captured photo path so the thumbnail
+ // persists across app restarts (the ByteArray is in-memory only).
+ val lastPhotoPath = getPhotosUseCase?.invoke()
+ ?.getOrNull()
+ ?.firstOrNull()
+ ?.filePath
+
_uiState.value = _uiState.value.copy(
currentScene = scene,
placedObjects = scene.objects,
- isLoading = false
+ isLoading = false,
+ lastCapturedPhotoPath = lastPhotoPath
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
@@ -226,7 +281,7 @@ class ARViewModel(
moveObject(currentState.objectId, currentState.currentPosition)
}
}
- else -> { /* No action needed */ }
+ else -> { /* No action needed for other states */ }
}
resetDragState()
}
@@ -272,4 +327,192 @@ class ARViewModel(
)
}
}
+
+ // ==================== Photo Capture Operations ====================
+
+ /**
+ * Request a photo capture from the AR view.
+ * This triggers the capture flow in PlatformARView.
+ */
+ fun requestCapture() {
+ if (capturePhotoUseCase == null) {
+ _uiState.value = _uiState.value.copy(
+ captureState = CaptureState.Error("Photo capture not available")
+ )
+ return
+ }
+
+ _uiState.value = _uiState.value.copy(
+ captureState = CaptureState.Capturing,
+ captureRequest = true
+ )
+ }
+
+ /**
+ * Handle the captured photo data from the AR view.
+ * Called by the UI when PixelCopy/snapshot completes.
+ */
+ fun onPhotoCaptured(imageData: ByteArray?) {
+ // Reset capture request
+ _uiState.value = _uiState.value.copy(captureRequest = false)
+
+ if (imageData == null) {
+ _uiState.value = _uiState.value.copy(
+ captureState = CaptureState.Error("Failed to capture photo")
+ )
+ return
+ }
+
+ if (capturePhotoUseCase == null) {
+ _uiState.value = _uiState.value.copy(
+ captureState = CaptureState.Error("Photo capture not available")
+ )
+ return
+ }
+
+ // Trigger shutter flash and store the photo bytes for the thumbnail immediately,
+ // before the async save completes.
+ _uiState.value = _uiState.value.copy(
+ lastCapturedPhotoData = imageData,
+ showShutterFlash = true
+ )
+
+ viewModelScope.launch {
+ capturePhotoUseCase.invoke(imageData).fold(
+ onSuccess = { photo ->
+ _uiState.value = _uiState.value.copy(
+ captureState = CaptureState.Success("Photo saved"),
+ lastCapturedPhotoPath = photo.filePath
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ captureState = CaptureState.Error(e.message ?: "Failed to save photo")
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Dismiss the shutter flash overlay once the animation has played.
+ */
+ fun clearShutterFlash() {
+ _uiState.value = _uiState.value.copy(showShutterFlash = false)
+ }
+
+ /**
+ * Clear the capture state (dismiss toast/snackbar).
+ */
+ fun clearCaptureState() {
+ _uiState.value = _uiState.value.copy(captureState = CaptureState.Idle)
+ }
+
+ // ==================== Video Recording Operations ====================
+
+ /**
+ * Start the recording duration timer.
+ * Updates recordingDurationSeconds every second.
+ */
+ private fun startRecordingTimer() {
+ recordingTimerJob?.cancel()
+ recordingTimerJob = viewModelScope.launch {
+ var seconds = 0L
+ while (isActive) {
+ _uiState.value = _uiState.value.copy(recordingDurationSeconds = seconds)
+ delay(1000)
+ seconds++
+ }
+ }
+ }
+
+ /**
+ * Stop the recording duration timer.
+ */
+ private fun stopRecordingTimer() {
+ recordingTimerJob?.cancel()
+ recordingTimerJob = null
+ _uiState.value = _uiState.value.copy(recordingDurationSeconds = 0L)
+ }
+
+ /**
+ * Start video recording.
+ */
+ fun startRecording() {
+ if (recordVideoUseCase == null) {
+ _uiState.value = _uiState.value.copy(
+ recordingState = RecordingState.Error("Video recording not available")
+ )
+ return
+ }
+
+ viewModelScope.launch {
+ recordVideoUseCase.startRecording().fold(
+ onSuccess = {
+ _uiState.value = _uiState.value.copy(
+ recordingState = RecordingState.Recording,
+ isRecording = true
+ )
+ startRecordingTimer()
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ recordingState = RecordingState.Error(e.message ?: "Failed to start recording"),
+ isRecording = false
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Stop video recording.
+ */
+ fun stopRecording() {
+ if (recordVideoUseCase == null) {
+ _uiState.value = _uiState.value.copy(
+ recordingState = RecordingState.Error("Video recording not available"),
+ isRecording = false
+ )
+ return
+ }
+
+ stopRecordingTimer()
+ _uiState.value = _uiState.value.copy(recordingState = RecordingState.Stopping)
+
+ viewModelScope.launch {
+ recordVideoUseCase.stopRecording().fold(
+ onSuccess = { video ->
+ _uiState.value = _uiState.value.copy(
+ recordingState = RecordingState.Success("Video saved (${video.durationMs / 1000}s)"),
+ isRecording = false
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ recordingState = RecordingState.Error(e.message ?: "Failed to save video"),
+ isRecording = false
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Toggle video recording state.
+ */
+ fun toggleRecording() {
+ if (_uiState.value.isRecording) {
+ stopRecording()
+ } else {
+ startRecording()
+ }
+ }
+
+ /**
+ * Clear the recording state (dismiss toast/snackbar).
+ */
+ fun clearRecordingState() {
+ _uiState.value = _uiState.value.copy(recordingState = RecordingState.Idle)
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/viewmodel/GalleryViewModel.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/viewmodel/GalleryViewModel.kt
new file mode 100644
index 0000000..6d74300
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/viewmodel/GalleryViewModel.kt
@@ -0,0 +1,222 @@
+package com.trendhive.arsample.presentation.viewmodel
+
+import com.trendhive.arsample.application.usecase.DeletePhotoUseCase
+import com.trendhive.arsample.application.usecase.DeleteVideoUseCase
+import com.trendhive.arsample.application.usecase.GetPhotosUseCase
+import com.trendhive.arsample.application.usecase.GetVideosUseCase
+import com.trendhive.arsample.domain.model.CapturedPhoto
+import com.trendhive.arsample.domain.model.CapturedVideo
+import com.trendhive.arsample.domain.model.MediaItem
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * Filter options for gallery media display.
+ */
+enum class GalleryFilter {
+ ALL,
+ PHOTOS,
+ VIDEOS
+}
+
+/**
+ * UI State for the Gallery screen.
+ */
+data class GalleryUiState(
+ val photos: List = emptyList(),
+ val videos: List = emptyList(),
+ val filter: GalleryFilter = GalleryFilter.ALL,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val selectedMedia: MediaItem? = null,
+ val isPreviewVisible: Boolean = false
+) {
+ /**
+ * Get filtered media items based on current filter.
+ * Returns a combined list sorted by timestamp (newest first).
+ */
+ val filteredMedia: List
+ get() {
+ val photoItems = photos.map { MediaItem.Photo(it) }
+ val videoItems = videos.map { MediaItem.Video(it) }
+
+ return when (filter) {
+ GalleryFilter.ALL -> (photoItems + videoItems).sortedByDescending { it.timestamp }
+ GalleryFilter.PHOTOS -> photoItems.sortedByDescending { it.timestamp }
+ GalleryFilter.VIDEOS -> videoItems.sortedByDescending { it.timestamp }
+ }
+ }
+
+ /**
+ * Check if gallery is empty for the current filter.
+ */
+ val isEmpty: Boolean
+ get() = filteredMedia.isEmpty()
+
+ /**
+ * Get counts for display.
+ */
+ val photoCount: Int get() = photos.size
+ val videoCount: Int get() = videos.size
+ val totalCount: Int get() = photoCount + videoCount
+}
+
+/**
+ * ViewModel for the Gallery screen.
+ * Manages media (photos and videos) display and operations.
+ */
+class GalleryViewModel(
+ private val getPhotosUseCase: GetPhotosUseCase,
+ private val getVideosUseCase: GetVideosUseCase,
+ private val deletePhotoUseCase: DeletePhotoUseCase,
+ private val deleteVideoUseCase: DeleteVideoUseCase
+) {
+
+ private val _uiState = MutableStateFlow(GalleryUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ init {
+ loadMedia()
+ }
+
+ /**
+ * Load all media (photos and videos).
+ */
+ fun loadMedia() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, error = null)
+
+ try {
+ // Load photos and videos in parallel using coroutineScope
+ coroutineScope {
+ val photosDeferred = async { getPhotosUseCase() }
+ val videosDeferred = async { getVideosUseCase() }
+
+ val photosResult = photosDeferred.await()
+ val videosResult = videosDeferred.await()
+
+ val photos = photosResult.getOrElse { emptyList() }
+ val videos = videosResult.getOrElse { emptyList() }
+
+ // Check for partial failures
+ val errors = mutableListOf()
+ if (photosResult.isFailure) {
+ errors.add("Photos: ${photosResult.exceptionOrNull()?.message}")
+ }
+ if (videosResult.isFailure) {
+ errors.add("Videos: ${videosResult.exceptionOrNull()?.message}")
+ }
+
+ _uiState.value = _uiState.value.copy(
+ photos = photos,
+ videos = videos,
+ isLoading = false,
+ error = if (errors.isNotEmpty()) errors.joinToString("; ") else null
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ error = e.message ?: "Failed to load media"
+ )
+ }
+ }
+ }
+
+ /**
+ * Set the filter for media display.
+ */
+ fun setFilter(filter: GalleryFilter) {
+ _uiState.value = _uiState.value.copy(filter = filter)
+ }
+
+ /**
+ * Delete a photo by ID.
+ */
+ fun deletePhoto(id: String) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, error = null)
+
+ deletePhotoUseCase(id).fold(
+ onSuccess = {
+ // Remove from local state and reload
+ _uiState.value = _uiState.value.copy(
+ photos = _uiState.value.photos.filter { it.id != id },
+ isLoading = false,
+ selectedMedia = null,
+ isPreviewVisible = false
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ error = e.message ?: "Failed to delete photo"
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Delete a video by ID.
+ */
+ fun deleteVideo(id: String) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, error = null)
+
+ deleteVideoUseCase(id).fold(
+ onSuccess = {
+ // Remove from local state
+ _uiState.value = _uiState.value.copy(
+ videos = _uiState.value.videos.filter { it.id != id },
+ isLoading = false,
+ selectedMedia = null,
+ isPreviewVisible = false
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ error = e.message ?: "Failed to delete video"
+ )
+ }
+ )
+ }
+ }
+
+ /**
+ * Select a media item for preview.
+ */
+ fun selectMedia(item: MediaItem) {
+ _uiState.value = _uiState.value.copy(
+ selectedMedia = item,
+ isPreviewVisible = true
+ )
+ }
+
+ /**
+ * Close the preview.
+ */
+ fun closePreview() {
+ _uiState.value = _uiState.value.copy(
+ selectedMedia = null,
+ isPreviewVisible = false
+ )
+ }
+
+ /**
+ * Clear error state.
+ */
+ fun clearError() {
+ _uiState.value = _uiState.value.copy(error = null)
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnailHelperTest.kt b/composeApp/src/commonTest/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnailHelperTest.kt
new file mode 100644
index 0000000..5eef84e
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnailHelperTest.kt
@@ -0,0 +1,297 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Unit tests for [ModelPreviewThumbnailHelper].
+ *
+ * These tests cover:
+ * - iOS extension-routing logic (resolvePreviewStrategy)
+ * - Android initial-state determination (resolveInitialAndroidState)
+ * - Android post-load state determination (resolveAndroidStateAfterLoad)
+ * - Pure extension-extraction utility (extractExtension)
+ *
+ * @see ModelPreviewThumbnail (expect declaration)
+ * @see ModelPreviewThumbnailHelper (subject under test)
+ */
+class ModelPreviewThumbnailHelperTest {
+
+ // -------------------------------------------------------------------------
+ // resolvePreviewStrategy — iOS routing
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun `resolvePreviewStrategy with usdz extension should return USDZ strategy`() {
+ // GIVEN
+ val modelPath = "/models/chair.usdz"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.USDZ, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with glb extension should return GLB_THUMBNAIL strategy`() {
+ // GIVEN
+ val modelPath = "/models/chair.glb"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.GLB_THUMBNAIL, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with gltf extension should return GLB_THUMBNAIL strategy`() {
+ // GIVEN
+ val modelPath = "/models/scene.gltf"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.GLB_THUMBNAIL, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with unknown extension should return PLACEHOLDER strategy`() {
+ // GIVEN
+ val modelPath = "/models/asset.fbx"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.PLACEHOLDER, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with no extension should return PLACEHOLDER strategy`() {
+ // GIVEN
+ val modelPath = "/models/noextension"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.PLACEHOLDER, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with empty path should return PLACEHOLDER strategy`() {
+ // GIVEN
+ val modelPath = ""
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.PLACEHOLDER, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with txt extension should return PLACEHOLDER strategy`() {
+ // GIVEN
+ val modelPath = "/models/readme.txt"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.PLACEHOLDER, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with uppercase USDZ extension should return USDZ strategy`() {
+ // GIVEN - extension casing should be normalised
+ val modelPath = "/models/chair.USDZ"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.USDZ, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with uppercase GLB extension should return GLB_THUMBNAIL strategy`() {
+ // GIVEN
+ val modelPath = "/models/chair.GLB"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.GLB_THUMBNAIL, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with mixed-case GLTF extension should return GLB_THUMBNAIL strategy`() {
+ // GIVEN
+ val modelPath = "/models/scene.GlTf"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.GLB_THUMBNAIL, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with path containing dots in directory name should use last segment`() {
+ // GIVEN - a path with dots in intermediate directory names
+ val modelPath = "/app/v1.2.3/models/chair.usdz"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN – the extension of the final filename is "usdz"
+ assertEquals(PreviewStrategy.USDZ, result)
+ }
+
+ @Test
+ fun `resolvePreviewStrategy with path containing dots in directory and glb file`() {
+ // GIVEN
+ val modelPath = "/app/v1.2.3/models/chair.glb"
+
+ // WHEN
+ val result = ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)
+
+ // THEN
+ assertEquals(PreviewStrategy.GLB_THUMBNAIL, result)
+ }
+
+ // -------------------------------------------------------------------------
+ // resolveInitialAndroidState — Android file-existence gate
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun `resolveInitialAndroidState when file exists should return Loading`() {
+ // GIVEN
+ val fileExists = true
+
+ // WHEN
+ val state = ModelPreviewThumbnailHelper.resolveInitialAndroidState(fileExists)
+
+ // THEN
+ assertEquals(ThumbnailState.Loading, state)
+ }
+
+ @Test
+ fun `resolveInitialAndroidState when file does not exist should return Error`() {
+ // GIVEN
+ val fileExists = false
+
+ // WHEN
+ val state = ModelPreviewThumbnailHelper.resolveInitialAndroidState(fileExists)
+
+ // THEN
+ assertEquals(ThumbnailState.Error, state)
+ }
+
+ // -------------------------------------------------------------------------
+ // resolveAndroidStateAfterLoad — Android post-load state
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun `resolveAndroidStateAfterLoad when instance loaded successfully should return Loaded`() {
+ // GIVEN
+ val instanceLoaded = true
+
+ // WHEN
+ val state = ModelPreviewThumbnailHelper.resolveAndroidStateAfterLoad(instanceLoaded)
+
+ // THEN
+ assertEquals(ThumbnailState.Loaded, state)
+ }
+
+ @Test
+ fun `resolveAndroidStateAfterLoad when instance is null should return Error`() {
+ // GIVEN – createModelInstance returned null
+ val instanceLoaded = false
+
+ // WHEN
+ val state = ModelPreviewThumbnailHelper.resolveAndroidStateAfterLoad(instanceLoaded)
+
+ // THEN
+ assertEquals(ThumbnailState.Error, state)
+ }
+
+ // -------------------------------------------------------------------------
+ // State machine transitions — combined scenarios
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun `android happy path transitions from Loading to Loaded`() {
+ // GIVEN - file exists (initial state = Loading)
+ val initialState = ModelPreviewThumbnailHelper.resolveInitialAndroidState(fileExists = true)
+ assertEquals(ThumbnailState.Loading, initialState)
+
+ // WHEN - model loads successfully
+ val finalState = ModelPreviewThumbnailHelper.resolveAndroidStateAfterLoad(instanceLoaded = true)
+
+ // THEN
+ assertEquals(ThumbnailState.Loaded, finalState)
+ }
+
+ @Test
+ fun `android error path transitions directly to Error when file missing`() {
+ // GIVEN + WHEN
+ val state = ModelPreviewThumbnailHelper.resolveInitialAndroidState(fileExists = false)
+
+ // THEN - Error without reaching Loading or Loaded
+ assertEquals(ThumbnailState.Error, state)
+ }
+
+ @Test
+ fun `android error path transitions from Loading to Error when createModelInstance returns null`() {
+ // GIVEN - file exists
+ val initialState = ModelPreviewThumbnailHelper.resolveInitialAndroidState(fileExists = true)
+ assertEquals(ThumbnailState.Loading, initialState)
+
+ // WHEN - model loader returns null
+ val finalState = ModelPreviewThumbnailHelper.resolveAndroidStateAfterLoad(instanceLoaded = false)
+
+ // THEN
+ assertEquals(ThumbnailState.Error, finalState)
+ }
+
+ // -------------------------------------------------------------------------
+ // extractExtension — utility
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun `extractExtension should return lowercase extension from simple path`() {
+ assertEquals("glb", ModelPreviewThumbnailHelper.extractExtension("/models/chair.glb"))
+ }
+
+ @Test
+ fun `extractExtension should normalise uppercase to lowercase`() {
+ assertEquals("usdz", ModelPreviewThumbnailHelper.extractExtension("/models/chair.USDZ"))
+ }
+
+ @Test
+ fun `extractExtension should return empty string when no extension present`() {
+ assertEquals("", ModelPreviewThumbnailHelper.extractExtension("/models/noext"))
+ }
+
+ @Test
+ fun `extractExtension should return empty string for empty path`() {
+ assertEquals("", ModelPreviewThumbnailHelper.extractExtension(""))
+ }
+
+ @Test
+ fun `extractExtension should return last segment extension when path contains multiple dots`() {
+ assertEquals("glb", ModelPreviewThumbnailHelper.extractExtension("/app/v1.2/chair.glb"))
+ }
+
+ @Test
+ fun `extractExtension should return empty string when path ends with a dot`() {
+ // edge case: file named "chair."
+ assertEquals("", ModelPreviewThumbnailHelper.extractExtension("/models/chair."))
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/MainViewController.kt
index 27a89cd..ed3b6d4 100644
--- a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/MainViewController.kt
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/MainViewController.kt
@@ -1,39 +1,10 @@
package com.trendhive.arsample
import androidx.compose.ui.window.ComposeUIViewController
-import com.trendhive.arsample.infrastructure.persistence.local.*
-import com.trendhive.arsample.infrastructure.persistence.repository.*
-import com.trendhive.arsample.application.usecase.*
import kotlinx.cinterop.ExperimentalForeignApi
@OptIn(ExperimentalForeignApi::class)
fun MainViewController() = ComposeUIViewController {
- // Simple DI setup for iOS
- val modelFileStorage = ModelFileStorageIOSImpl()
- val objectLocalDataSource = ARObjectLocalDataSourceIOSImpl()
- val sceneDataStore = ARSceneDataStoreIOSImpl()
-
- val objectRepository = ARObjectRepositoryImpl(objectLocalDataSource, modelFileStorage)
- val sceneRepository = ARSceneRepositoryImpl(sceneDataStore)
-
- val importObjectUseCase = ImportObjectUseCase(objectRepository)
- val getAllObjectsUseCase = GetAllObjectsUseCase(objectRepository)
- val deleteObjectUseCase = DeleteObjectUseCase(objectRepository)
- val placeObjectInSceneUseCase = PlaceObjectInSceneUseCase(sceneRepository, objectRepository)
- val removeObjectFromSceneUseCase = RemoveObjectFromSceneUseCase(sceneRepository)
- val getSceneUseCase = GetSceneUseCase(sceneRepository)
- val saveSceneUseCase = SaveSceneUseCase(sceneRepository)
- val moveObjectUseCase = MoveObjectUseCase(sceneRepository)
-
- App(
- importObjectUseCase = importObjectUseCase,
- getAllObjectsUseCase = getAllObjectsUseCase,
- deleteObjectUseCase = deleteObjectUseCase,
- placeObjectInSceneUseCase = placeObjectInSceneUseCase,
- removeObjectFromSceneUseCase = removeObjectFromSceneUseCase,
- getSceneUseCase = getSceneUseCase,
- saveSceneUseCase = saveSceneUseCase,
- moveObjectUseCase = moveObjectUseCase,
- sceneRepository = sceneRepository
- )
+ // Koin handles DI - see App.kt for ViewModels injection via koinInject()
+ App()
}
\ No newline at end of file
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/ARViewWrapper.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/ARViewWrapper.kt
index 3aa8db6..d75eb49 100644
--- a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/ARViewWrapper.kt
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/ARViewWrapper.kt
@@ -27,7 +27,9 @@ fun ARViewWrapper(
onModelPlaced: (modelPath: String, posX: Float, posY: Float, posZ: Float, scale: Float) -> Unit,
onModelRemoved: (anchorId: String) -> Unit = {},
modelPathToLoad: String? = null,
- onObjectScaleChanged: (objectId: String, newScale: Float) -> Unit = { _, _ -> }
+ onObjectScaleChanged: (objectId: String, newScale: Float) -> Unit = { _, _ -> },
+ captureRequest: Boolean = false,
+ onCaptureComplete: ((ByteArray?) -> Unit)? = null
) {
var arView by remember { mutableStateOf(null) }
@@ -101,6 +103,57 @@ fun ARViewWrapper(
}
}
+ // Handle capture request
+ LaunchedEffect(captureRequest) {
+ if (captureRequest && onCaptureComplete != null) {
+ val view = arView
+ if (view == null) {
+ onCaptureComplete(null)
+ return@LaunchedEffect
+ }
+
+ try {
+ // Capture snapshot of the AR view
+ val snapshot = view.snapshot()
+ if (snapshot == null) {
+ println("$TAG: Snapshot returned null")
+ onCaptureComplete(null)
+ return@LaunchedEffect
+ }
+
+ // Convert UIImage to JPEG data
+ val jpegData = platform.UIKit.UIImageJPEGRepresentation(snapshot, 0.9)
+ if (jpegData == null) {
+ println("$TAG: JPEG conversion returned null")
+ onCaptureComplete(null)
+ return@LaunchedEffect
+ }
+
+ // Convert NSData to ByteArray
+ val bytes = jpegData.bytes
+ val length = jpegData.length.toInt()
+ if (bytes == null || length == 0) {
+ onCaptureComplete(null)
+ return@LaunchedEffect
+ }
+
+ val byteArray = ByteArray(length)
+ kotlinx.cinterop.memScoped {
+ val ptr = bytes.reinterpret()
+ for (i in 0 until length) {
+ byteArray[i] = ptr[i]
+ }
+ }
+
+ println("$TAG: Captured photo with ${byteArray.size} bytes")
+ onCaptureComplete(byteArray)
+ } catch (e: Exception) {
+ println("$TAG: ERROR - Capture failed: ${e.message}")
+ onCaptureComplete(null)
+ }
+ }
+ }
+
UIKitView(
factory = {
println("$TAG: Creating ARSCNView")
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/PlatformARView.ios.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/PlatformARView.ios.kt
index 774fac2..9065629 100644
--- a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/PlatformARView.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/ar/PlatformARView.ios.kt
@@ -15,14 +15,22 @@ actual fun PlatformARView(
onObjectPositionChanged: ((placedObjectId: String, x: Float, y: Float, z: Float) -> Unit)?,
onDragStart: ((objectId: String) -> Unit)?,
onDragMove: ((objectId: String, screenX: Float, screenY: Float) -> Unit)?,
- onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)?
+ onDragEnd: ((objectId: String, screenX: Float, screenY: Float) -> Unit)?,
+ captureRequest: Boolean,
+ onCaptureComplete: ((ByteArray?) -> Unit)?,
+ onRecordingCallbacksReady: ((onStart: (String) -> Boolean, onStop: () -> Boolean) -> Unit)?,
+ onRecordingCallbacksClear: (() -> Unit)?
) {
+ // Note: Video recording not yet implemented for iOS
+ // The callbacks are ignored for now
ARViewWrapper(
modifier = modifier,
placedObjects = placedObjects,
onModelPlaced = onModelPlaced,
onModelRemoved = onModelRemoved,
modelPathToLoad = modelPathToLoad,
- onObjectScaleChanged = onObjectScaleChanged
+ onObjectScaleChanged = onObjectScaleChanged,
+ captureRequest = captureRequest,
+ onCaptureComplete = onCaptureComplete
)
}
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/di/PlatformModule.ios.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/di/PlatformModule.ios.kt
new file mode 100644
index 0000000..5076fc3
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/di/PlatformModule.ios.kt
@@ -0,0 +1,18 @@
+package com.trendhive.arsample.di
+
+import com.trendhive.arsample.domain.repository.MediaRepository
+import com.trendhive.arsample.infrastructure.persistence.local.ARObjectLocalDataSource
+import com.trendhive.arsample.infrastructure.persistence.local.ARSceneDataStore
+import com.trendhive.arsample.infrastructure.persistence.local.ModelFileStorage
+import com.trendhive.arsample.infrastructure.persistence.local.ARObjectLocalDataSourceIOSImpl
+import com.trendhive.arsample.infrastructure.persistence.local.ARSceneDataStoreIOSImpl
+import com.trendhive.arsample.infrastructure.persistence.local.ModelFileStorageIOSImpl
+import com.trendhive.arsample.infrastructure.persistence.local.MediaRepositoryIOSImpl
+import org.koin.dsl.module
+
+actual fun platformDataSourceModule() = module {
+ single { ARObjectLocalDataSourceIOSImpl() }
+ single { ARSceneDataStoreIOSImpl() }
+ single { ModelFileStorageIOSImpl() }
+ single { MediaRepositoryIOSImpl() }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/infrastructure/persistence/local/MediaRepositoryIOSImpl.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/infrastructure/persistence/local/MediaRepositoryIOSImpl.kt
new file mode 100644
index 0000000..c5d530d
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/infrastructure/persistence/local/MediaRepositoryIOSImpl.kt
@@ -0,0 +1,61 @@
+package com.trendhive.arsample.infrastructure.persistence.local
+
+import com.trendhive.arsample.domain.exception.StorageException
+import com.trendhive.arsample.domain.model.CapturedPhoto
+import com.trendhive.arsample.domain.model.CapturedVideo
+import com.trendhive.arsample.domain.repository.MediaRepository
+
+/**
+ * iOS implementation of MediaRepository.
+ * Uses Photos framework for saving media to the device.
+ *
+ * Note: Video recording implementation requires native Swift/Objective-C
+ * integration with ARKit's ReplayKit or custom recording solution.
+ */
+class MediaRepositoryIOSImpl : MediaRepository {
+
+ private var _isRecording = false
+
+ // ==================== Photo Operations ====================
+
+ override suspend fun savePhoto(imageData: ByteArray, filename: String): Result {
+ // TODO: Implement using UIImageWriteToSavedPhotosAlbum or Photos framework
+ return Result.failure(StorageException("Photo capture not yet implemented on iOS"))
+ }
+
+ override suspend fun getPhotos(): Result> {
+ // TODO: Implement using Photos framework PHFetchRequest
+ return Result.success(emptyList())
+ }
+
+ override suspend fun deletePhoto(id: String): Result {
+ // TODO: Implement using Photos framework
+ return Result.failure(StorageException("Photo deletion not yet implemented on iOS"))
+ }
+
+ // ==================== Video Operations ====================
+
+ override suspend fun startVideoRecording(): Result {
+ // TODO: Implement using ReplayKit or custom ARKit recording
+ // This would require native Swift code and interop
+ return Result.failure(StorageException("Video recording not yet implemented on iOS"))
+ }
+
+ override suspend fun stopVideoRecording(): Result {
+ // TODO: Implement using ReplayKit or custom ARKit recording
+ _isRecording = false
+ return Result.failure(StorageException("Video recording not yet implemented on iOS"))
+ }
+
+ override suspend fun getVideos(): Result> {
+ // TODO: Implement using Photos framework PHFetchRequest with video media type
+ return Result.success(emptyList())
+ }
+
+ override suspend fun deleteVideo(id: String): Result {
+ // TODO: Implement using Photos framework
+ return Result.failure(StorageException("Video deletion not yet implemented on iOS"))
+ }
+
+ override fun isRecording(): Boolean = _isRecording
+}
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.ios.kt
new file mode 100644
index 0000000..2932f0b
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewEngineProvider.ios.kt
@@ -0,0 +1,14 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import androidx.compose.runtime.Composable
+
+/**
+ * iOS actual implementation of [ModelPreviewEngineProvider].
+ *
+ * iOS thumbnails use SceneKit SCNView or QLThumbnailGenerator — neither requires a
+ * shared Filament Engine. This is a no-op pass-through.
+ */
+@Composable
+actual fun ModelPreviewEngineProvider(content: @Composable () -> Unit) {
+ content()
+}
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.ios.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.ios.kt
new file mode 100644
index 0000000..afda1dc
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.ios.kt
@@ -0,0 +1,172 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.UIKitView
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.CoreGraphics.CGRectMake
+import platform.Foundation.NSURL
+import platform.SceneKit.SCNAction
+import platform.SceneKit.SCNAntialiasingMode
+import platform.SceneKit.SCNCamera
+import platform.SceneKit.SCNNode
+import platform.SceneKit.SCNScene
+import platform.SceneKit.SCNVector3Make
+import platform.SceneKit.SCNView
+import platform.UIKit.UIColor
+
+private const val TAG = "ModelPreviewThumbnail"
+
+/**
+ * iOS implementation of 3D model preview thumbnail.
+ *
+ * Strategy:
+ * - USDZ -> SCNView (SceneKit) embedded via UIKitView. Auto-rotation via SCNAction.
+ * - GLB/GLTF -> Placeholder icon. QLThumbnailGenerator is not available in
+ * Kotlin/Native cinterop bindings; GLB is also not the primary iOS format.
+ * - Fallback -> Placeholder icon for unknown formats or load failures.
+ */
+@OptIn(ExperimentalForeignApi::class)
+@Composable
+actual fun ModelPreviewThumbnail(
+ modelPath: String,
+ modifier: Modifier,
+ autoRotate: Boolean
+) {
+ when (ModelPreviewThumbnailHelper.resolvePreviewStrategy(modelPath)) {
+ PreviewStrategy.USDZ -> USDZPreview(modelPath = modelPath, modifier = modifier, autoRotate = autoRotate)
+ PreviewStrategy.GLB_THUMBNAIL -> PlaceholderPreview(modifier = modifier)
+ PreviewStrategy.PLACEHOLDER -> PlaceholderPreview(modifier = modifier)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// USDZ -> SCNView (SceneKit)
+// ---------------------------------------------------------------------------
+
+@OptIn(ExperimentalForeignApi::class)
+@Composable
+private fun USDZPreview(
+ modelPath: String,
+ modifier: Modifier,
+ autoRotate: Boolean
+) {
+ val sceneRef = remember { mutableStateOf(null) }
+
+ DisposableEffect(modelPath) {
+ sceneRef.value?.let { configureSceneView(it, modelPath, autoRotate) }
+ onDispose {
+ sceneRef.value?.scene?.rootNode?.removeAllActions()
+ }
+ }
+
+ UIKitView(
+ factory = {
+ val scnView = SCNView(frame = CGRectMake(0.0, 0.0, 1.0, 1.0), options = null)
+ scnView.backgroundColor = UIColor.clearColor
+ scnView.autoenablesDefaultLighting = true
+ scnView.antialiasingMode = SCNAntialiasingMode.SCNAntialiasingModeMultisampling4X
+ scnView.allowsCameraControl = false
+ configureSceneView(scnView, modelPath, autoRotate)
+ sceneRef.value = scnView
+ scnView
+ },
+ modifier = modifier.clip(RoundedCornerShape(8.dp))
+ )
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private fun configureSceneView(scnView: SCNView, modelPath: String, autoRotate: Boolean) {
+ try {
+ val fileURL = NSURL.fileURLWithPath(modelPath)
+ val scene = SCNScene.sceneWithURL(fileURL, options = null, error = null)
+ if (scene == null) {
+ println("$TAG: SCNScene failed to load from $modelPath")
+ return
+ }
+
+ scnView.scene = scene
+
+ val cameraNode = SCNNode().apply { camera = SCNCamera() }
+ cameraNode.position = SCNVector3Make(0f, 0.15f, 0.5f)
+ scene.rootNode.addChildNode(cameraNode)
+ scnView.pointOfView = cameraNode
+
+ if (autoRotate) {
+ val spin = SCNAction.repeatActionForever(
+ SCNAction.rotateByX(0.0, 1.5, 0.0, duration = 3.0)
+ )
+ scene.rootNode.runAction(spin)
+ } else {
+ scene.rootNode.removeAllActions()
+ }
+ } catch (e: Exception) {
+ println("$TAG: Exception configuring SCNView: ${e.message}")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Placeholder (GLB + unknown formats)
+// ---------------------------------------------------------------------------
+
+@Composable
+private fun PlaceholderPreview(modifier: Modifier) {
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = ViewInArIconPreview,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
+ )
+ }
+}
+
+private val ViewInArIconPreview: ImageVector
+ get() = ImageVector.Builder(
+ name = "ViewInAr",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f
+ ).apply {
+ path(fill = SolidColor(Color.Black)) {
+ moveTo(3f, 4f); lineTo(3f, 10f); lineTo(5f, 10f); lineTo(5f, 6f)
+ lineTo(9f, 6f); lineTo(9f, 4f); close()
+
+ moveTo(15f, 4f); lineTo(15f, 6f); lineTo(19f, 6f); lineTo(19f, 10f)
+ lineTo(21f, 10f); lineTo(21f, 4f); close()
+
+ moveTo(3f, 14f); lineTo(3f, 20f); lineTo(9f, 20f); lineTo(9f, 18f)
+ lineTo(5f, 18f); lineTo(5f, 14f); close()
+
+ moveTo(15f, 18f); lineTo(15f, 20f); lineTo(21f, 20f); lineTo(21f, 14f)
+ lineTo(19f, 14f); lineTo(19f, 18f); close()
+
+ moveTo(12f, 8f); lineTo(8f, 10.5f); lineTo(8f, 15.5f); lineTo(12f, 18f)
+ lineTo(16f, 15.5f); lineTo(16f, 10.5f); close()
+
+ moveTo(12f, 9.5f); lineTo(14.5f, 11f); lineTo(14.5f, 14f); lineTo(12f, 15.5f)
+ lineTo(9.5f, 14f); lineTo(9.5f, 11f); close()
+ }
+ }.build()
diff --git a/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.ios.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.ios.kt
new file mode 100644
index 0000000..e57dc43
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/PhotoThumbnail.ios.kt
@@ -0,0 +1,25 @@
+package com.trendhive.arsample.presentation.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+
+@Composable
+actual fun PhotoThumbnail(uri: String?, modifier: Modifier) {
+ Box(
+ modifier = modifier.background(Color.DarkGray),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Image,
+ contentDescription = null,
+ tint = Color.White
+ )
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 400f90d..8c4e85f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,6 +22,9 @@ kotlinx-coroutines-test = "1.9.0"
material3 = "1.10.0-alpha05"
gson = "2.11.0"
mockk = "1.14.9"
+koin = "3.5.6"
+kover = "0.7.6"
+koin-compose = "1.1.5"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -51,6 +54,9 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
arcore = { module = "com.google.ar:core", version.ref = "arcore" }
sceneview = { module = "io.github.sceneview:arsceneview", version.ref = "sceneview" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
+koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
+koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
+koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
@@ -58,4 +64,5 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
-kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
\ No newline at end of file
+kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
\ No newline at end of file