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