diff --git a/.claude/WORKFLOW_EXAMPLES.md b/.claude/WORKFLOW_EXAMPLES.md index 82e9046..9a7d5db 100644 --- a/.claude/WORKFLOW_EXAMPLES.md +++ b/.claude/WORKFLOW_EXAMPLES.md @@ -251,12 +251,12 @@ git checkout -b ci/ios-workflow # 2. Create workflow files # Created: # - .github/workflows/ios-ci.yml -# - .github/workflows/IOS_WORKFLOW_README.md +# - docs/ios/ios-workflow-readme.md # - iosApp/.swiftlint.yml # 3. Commit git add .github/workflows/ios-ci.yml -git add .github/workflows/IOS_WORKFLOW_README.md +git add docs/ios/ios-workflow-readme.md git add iosApp/.swiftlint.yml git commit -m "ci: add GitHub Actions workflow for iOS builds diff --git a/.claude/agents/README.md b/.claude/agents/README.md index e9e50b1..c9627b4 100644 --- a/.claude/agents/README.md +++ b/.claude/agents/README.md @@ -1,6 +1,6 @@ # Agents -Central documentation containing 6 agent definitions and task specifications. +Central documentation containing 7 agent definitions and task specifications. ## πŸ”„ Development Workflow @@ -25,8 +25,9 @@ Each agent follows this flow: | 2 | [Android Expert](./android-expert-agent.md) | ARCore research, Android report | Android report | | 3 | [iOS Expert](./ios-expert-agent.md) | ARKit research, iOS report | iOS report | | 4 | [Main Developer](./main-developer-agent.md) | Code development (domain, application, infrastructure, presentation) | Code files | -| 5 | [Test Developer](./test-developer-agent.md) | Unit testing | Test files | -| 6 | [Code Reviewer](./code-reviewer-agent.md) | Code standards verification | Review report | +| 5 | [Bug Fixer](./bug-fixer-agent.md) | Debugging and bug fixing | Bug reports, fixed code | +| 6 | [Test Developer](./test-developer-agent.md) | Unit testing | Test files | +| 7 | [Code Reviewer](./code-reviewer-agent.md) | Code standards verification | Review report | ## Agent Communication Flow @@ -70,5 +71,6 @@ Main Developer (fixes) 2. [Android Expert Agent](./android-expert-agent.md) - Android ARCore implementation 3. [iOS Expert Agent](./ios-expert-agent.md) - iOS ARKit implementation 4. [Main Developer Agent](./main-developer-agent.md) - Core code development -5. [Test Developer Agent](./test-developer-agent.md) - Unit tests -6. [Code Reviewer Agent](./code-reviewer-agent.md) - Code quality control \ No newline at end of file +5. [Bug Fixer Agent](./bug-fixer-agent.md) - Debugging and bug fixing +6. [Test Developer Agent](./test-developer-agent.md) - Unit tests +7. [Code Reviewer Agent](./code-reviewer-agent.md) - Code quality control \ No newline at end of file diff --git a/INDEX.md b/INDEX.md deleted file mode 100644 index 26474f3..0000000 --- a/INDEX.md +++ /dev/null @@ -1,220 +0,0 @@ -# ARSample - Documentation Hub - -![Architecture](https://img.shields.io/badge/Architecture-DDD-green) -![Platform](https://img.shields.io/badge/Platform-Kotlin%20Multiplatform-blue) -![Android](https://img.shields.io/badge/Android-ARCore-green) -![iOS](https://img.shields.io/badge/iOS-ARKit-black) - -> **AR Sample Application** - A Kotlin Multiplatform app for importing and placing 3D objects in AR scenes on both Android (ARCore) and iOS (ARKit). - ---- - -## πŸš€ Quick Start - -| Document | Description | -|----------|-------------| -| **[README.md](./README.md)** | Project overview and setup instructions | -| **[CLAUDE.md](./CLAUDE.md)** | AI assistant configuration and key patterns | -| **[.github/copilot-instructions.md](./.github/copilot-instructions.md)** | GitHub Copilot instructions | -| **[docs/INDEX.md](./docs/INDEX.md)** | Complete documentation index | - ---- - -## πŸ“‚ Documentation Structure - -All documentation is organized in the `docs/` folder: - -``` -docs/ -β”œβ”€β”€ INDEX.md # Master documentation index -β”œβ”€β”€ architecture/ # Architecture and design docs -β”œβ”€β”€ design/ # UI/UX design system and guidelines -β”œβ”€β”€ agents/ # Multi-agent system documentation -β”œβ”€β”€ ios/ # iOS-specific documentation -β”œβ”€β”€ guides/ # How-to guides and checklists -└── reports/ # Status reports and summaries -``` - -### πŸ“– Quick Navigation - -- **[Full Documentation Index](./docs/INDEX.md)** - Browse all documentation -- **[Architecture](./docs/architecture/TECHNICAL_ANALYSIS.md)** - System design -- **[UI/UX Design](./docs/design/)** - Design system and guidelines -- **[Agents System](./docs/agents/README.md)** - Development workflow -- **[iOS Guide](./docs/ios/ios-quick-reference.md)** - iOS implementation -- **[Testing Guide](./docs/guides/TEST_IMPLEMENTATION_GUIDE.md)** - Testing -- **[Status Reports](./docs/reports/)** - Project status - ---- - -## πŸ—οΈ Architecture Overview - -This project follows **Eric Evans' DDD + Clean Architecture + MVVM** pattern with strict layer separation: - -``` -Domain Layer (innermost) β†’ Pure business logic, NO dependencies - ↑ -Application Layer β†’ Use cases, depends on Domain only - ↑ -Infrastructure Layer β†’ Technical implementations, depends on Domain - ↑ -Presentation Layer β†’ UI, depends on Application -``` - -### Key Patterns - -- **Value Objects**: Domain validation (ModelUri, ObjectName) in `domain/model/valueobjects/` -- **Base Classes**: - - `BaseModel`, `BaseRepository` in `domain/base/` - - `BaseUseCase` in `application/base/` - - `BaseMapper` in `infrastructure/persistence/` -- **DTO/Mapper**: Separation between domain and persistence - - Persistence DTOs in `infrastructure/persistence/dto/` - - Mappers in `infrastructure/persistence/mapper/` -- **Result**: Functional error handling -- **Interface Segregation**: Every component has an interface - -### Layer Structure - -**1. Domain Layer** (`domain/`) -- `base/` - BaseModel, BaseRepository -- `model/` - Entities (ARObject, ARScene, PlacedObject) - - `valueobjects/` - ModelUri, ObjectName -- `repository/` - Repository interfaces -- `exception/` - Domain exceptions - -**2. Application Layer** (`application/`) -- `base/` - BaseUseCase -- `dto/` - Use case Input/Output DTOs -- `usecase/` - Business workflows - -**3. Infrastructure Layer** (`infrastructure/persistence/`) -- `dto/` - Persistence DTOs -- `mapper/` - DTO ↔ Model mappers -- `repository/` - Repository implementations -- `local/` - Data source interfaces -- `BaseMapper.kt` - Mapper base class - -**4. Presentation Layer** (`presentation/`) -- `viewmodel/` - State management -- `ui/` - Compose screens and components - -πŸ“š **[Read more about architecture β†’](./docs/architecture/TECHNICAL_ANALYSIS.md)** - ---- - -## πŸ‘₯ Multi-Agent Development System - -This project uses a multi-agent approach for development: - -| Agent | Role | -|-------|------| -| **Design & Analysis** | Research and architecture design | -| **Android Expert** | ARCore implementation | -| **iOS Expert** | ARKit implementation | -| **Main Developer** | Core feature development | -| **Test Developer** | Unit test creation | -| **Code Reviewer** | Quality control | -| **Bug Fixer** | Debugging and fixes | - -πŸ“š **[Learn about agents β†’](./.claude/agents/README.md)** - ---- - -## πŸ› οΈ Build & Test Commands - -**Android:** -```bash -./gradlew :composeApp:assembleDebug -``` - -**iOS:** -```bash -open iosApp/iosApp.xcodeproj # Then run from Xcode -``` - -**Run tests:** -```bash -./gradlew :composeApp:testDebugUnitTest -``` - -πŸ“š **[More commands in CLAUDE.md β†’](./CLAUDE.md)** - ---- - -## πŸ“± Platform Support - -- **Android**: ARCore + SceneView library -- **iOS**: ARKit + RealityKit -- **Models**: GLB (primary), USDZ (iOS) -- **Storage**: DataStore (Android), UserDefaults (iOS) - ---- - -## πŸ“Š Project Status - -| Category | Status | -|----------|--------| -| Core Architecture | βœ… Complete | -| Android Implementation | βœ… Complete | -| iOS Implementation | βœ… Complete | -| Unit Tests | βœ… Complete | -| Documentation | βœ… Complete | - -πŸ“š **[View detailed reports β†’](./docs/reports/)** - ---- - -## πŸ“š Documentation Categories - -### By Topic - -- **πŸ—οΈ Architecture**: [Technical Analysis](./docs/architecture/technical-analysis.md) -- **🎨 Design**: [UI/UX Design System](./docs/design/) -- **πŸ‘₯ Development**: [Agents System](./docs/agents/README.md) -- **πŸ“± iOS**: [iOS Documentation](./docs/ios/) -- **πŸ“– Guides**: [Implementation Guides](./docs/guides/) -- **πŸ“Š Reports**: [Status Reports](./docs/reports/) - -### By Role - -- **For Developers**: [Main Developer Agent](./.claude/agents/main-developer-agent.md) -- **For Testers**: [Test Implementation Guide](./docs/guides/test-implementation-guide.md) -- **For Reviewers**: [Code Review Checklist](./docs/guides/code-review-checklist.md) -- **For iOS Devs**: [iOS Quick Reference](./docs/ios/ios-quick-reference.md) -- **For Android Devs**: [Android Expert Agent](./.claude/agents/android-expert-agent.md) - ---- - -## πŸ”— External Resources - -- [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) -- [ARCore Developer Guide](https://developers.google.com/ar) -- [ARKit Documentation](https://developer.apple.com/documentation/arkit) -- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) -- [Domain-Driven Design](https://www.domainlanguage.com/ddd/reference/) - ---- - -## πŸ“ Recent Updates - -- **2026-03-30**: Added comprehensive [UI/UX Design System](./docs/design/) with Material 3 + iOS guidelines -- **2026-03-30**: Added [AR Competitor Analysis](./docs/design/AR_COMPETITOR_ANALYSIS.md) research -- **2026-03-30**: Added [Design Tokens](./docs/design/DESIGN_TOKENS.md) quick reference -- **2026-03-30**: Added [Drag-and-Drop Design Document](./docs/DRAG_DROP_DESIGN.md) -- **2026-03-31**: Documentation reorganized into `docs/` structure -- **2026-03-31**: Agents updated with Flutter-inspired DDD patterns -- **2026-03-31**: Added Value Objects, DTO/Mapper patterns -- **2026-03-30**: Initial implementation completed - ---- - -## πŸ“„ License - -This project is private and not published. - ---- - -

- πŸ“– For comprehensive documentation, visit **[docs/INDEX.md](./docs/INDEX.md)** -

diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c0aaf0f..497d4f3 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 { @@ -35,6 +36,7 @@ kotlin { implementation(libs.sceneview) implementation(libs.arcore) implementation(libs.gson) + implementation(libs.koin.android) } commonMain.dependencies { implementation(compose.runtime) @@ -48,6 +50,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 +92,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/kotlin/com/trendhive/arsample/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/MainActivity.kt index 138493d..f989775 100644 --- a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/MainActivity.kt @@ -5,10 +5,12 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.trendhive.arsample.infrastructure.persistence.local.* -import com.trendhive.arsample.infrastructure.persistence.repository.* -import com.trendhive.arsample.application.usecase.* -import java.io.File +import com.trendhive.arsample.di.appModules +import com.trendhive.arsample.di.platformDataSourceModule +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -18,36 +20,18 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) - // Simple DI setup for Android - val filesDir = applicationContext.filesDir - val modelFileStorage = ModelFileStorageImpl(applicationContext, filesDir) - val objectLocalDataSource = ARObjectLocalDataSourceImpl(filesDir) - val sceneDataStore = ARSceneDataStoreImpl(filesDir) - - 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) + // Initialize Koin DI only if not already started + // This prevents crash on Activity recreation (e.g., configuration change) + if (GlobalContext.getOrNull() == null) { + startKoin { + androidLogger() + androidContext(applicationContext) + modules(platformDataSourceModule() + appModules) + } + } setContent { - App( - importObjectUseCase = importObjectUseCase, - getAllObjectsUseCase = getAllObjectsUseCase, - deleteObjectUseCase = deleteObjectUseCase, - placeObjectInSceneUseCase = placeObjectInSceneUseCase, - removeObjectFromSceneUseCase = removeObjectFromSceneUseCase, - getSceneUseCase = getSceneUseCase, - saveSceneUseCase = saveSceneUseCase, - moveObjectUseCase = moveObjectUseCase, - sceneRepository = sceneRepository - ) + App() } } } diff --git a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/ARView.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/ARView.kt index 9becae1..32bc5c8 100644 --- a/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/ARView.kt +++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/ar/ARView.kt @@ -70,7 +70,12 @@ fun ARView( 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 ) { // CRITICAL FIX: Use rememberUpdatedState to ensure callbacks always reference latest values // This prevents AndroidView factory closure from capturing stale lambda references @@ -81,10 +86,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 +128,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 +138,8 @@ fun ARView( dragStartNodePosition = null dragTouchDownPosition = null dragTouchDownTime = 0L + dragLastScreenX = null + dragLastScreenY = null } // Helper function to restore dragged node's original state @@ -452,6 +476,103 @@ fun ARView( arSceneView?.destroy() } } + + // 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 { + // 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) + } + } + + // 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) + } + } + } val indicatorSize = 48.dp val indicatorRadiusPx = with(LocalDensity.current) { (indicatorSize / 2).roundToPx() } @@ -622,6 +743,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 @@ -648,49 +771,61 @@ fun ARView( 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 camera ray projection - // Keep object at same distance from camera, but move along screen + // 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 - val dx = oldPos.x - camPose.tx() - val dy = oldPos.y - camPose.ty() - val dz = oldPos.z - camPose.tz() - val dist = kotlin.math.sqrt(dx*dx + dy*dy + dz*dz) + // 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-normalized coordinates (-1 to 1) - val screenWidth = this.width.toFloat() - val screenHeight = this.height.toFloat() - val normX = (e.x / screenWidth) * 2 - 1 - val normY = 1 - (e.y / screenHeight) * 2 + // 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 - // Project to world using camera view matrix - val viewMatrix = FloatArray(16) - cam.getViewMatrix(viewMatrix, 0) + // 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) - // Simple approximation: move in camera's X/Y plane - // Get camera's right and up vectors from pose - val rightX = camPose.getXAxis()[0] - val rightY = camPose.getXAxis()[1] - val rightZ = camPose.getXAxis()[2] + // Get camera's right and forward vectors (horizontal only) + val rightVec = camPose.getXAxis() + val forwardVec = camPose.getZAxis() - val upX = camPose.getYAxis()[0] - val upY = camPose.getYAxis()[1] - val upZ = camPose.getYAxis()[2] + // 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 + } - // Scale movement based on distance (farther objects need bigger moves) - val scale = dist * 0.5f + // 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 - // Calculate new position - val newX = camPose.tx() - camPose.getZAxis()[0] * dist + rightX * normX * scale - val newY = oldPos.y // Keep Y (height) the same - val newZ = camPose.tz() - camPose.getZAxis()[2] * dist + rightZ * normX * scale + // 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 (raycast): updated position to ($newX, $newY, $newZ)") + Log.d(TAG, "Drag MOVE (fallback): delta=(${screenDeltaX}, ${screenDeltaY}) -> world=($newX, $newY, $newZ)") } } } 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/ModelPreviewThumbnail.android.kt b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.android.kt new file mode 100644 index 0000000..2b837a9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.android.kt @@ -0,0 +1,44 @@ +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.material.icons.Icons +import androidx.compose.material.icons.filled.ViewInAr +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +/** + * Android implementation of model preview thumbnail. + * + * NOTE: 3D SceneView preview temporarily disabled due to stability issues. + * Shows a placeholder icon instead. + * TODO: Re-enable 3D preview after SceneView stability is resolved. + */ +@Composable +actual fun ModelPreviewThumbnail( + modelPath: String, + modifier: Modifier, + autoRotate: Boolean +) { + // Simple placeholder - 3D preview disabled for stability + 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.primary.copy(alpha = 0.7f) + ) + } +} diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index b3ab5e3..44f5510 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -31,4 +31,9 @@ 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 d771c02..33e0867 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -31,4 +31,9 @@ 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..b4aa010 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,41 @@ 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() + + 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 +93,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 +124,18 @@ fun App( }, onDragEnd = { arViewModel.onDragEnd() + }, + onToggleRecording = { + arViewModel.toggleRecording() + }, + onClearRecordingState = { + arViewModel.clearRecordingState() + }, + onCapturePhoto = { + arViewModel.requestCapture() + }, + onOpenGallery = { + currentScreen = Screen.Gallery } ) } @@ -120,5 +146,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..c449e19 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/di/AppModule.kt @@ -0,0 +1,99 @@ +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 { + factory { ObjectListViewModel(get(), get(), get()) } + factory { + ARViewModel( + placeObjectUseCase = get(), + removeObjectUseCase = get(), + getSceneUseCase = get(), + saveSceneUseCase = get(), + sceneRepository = get(), + moveObjectUseCase = get(), + capturePhotoUseCase = get(), + recordVideoUseCase = get() + ) + } + factory { 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..039d9df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/components/CameraControls.kt @@ -0,0 +1,313 @@ +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 access. + * Professional camera-style layout with perfect symmetry using weighted sections. + * + * Layout: [Left Section] --- [Center Record] --- [Right Section] + * Left: Gallery button (aligned to end) + * Center: Large record button (fixed size) + * Right: Photo capture button (aligned to start) + */ +@Composable +fun CameraControlsBar( + isRecording: Boolean, + onCapturePhoto: () -> Unit, + onToggleRecording: () -> Unit, + onOpenGallery: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Left section - Gallery button (aligned to end of this section) + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd + ) { + CameraControlButton( + onClick = onOpenGallery, + enabled = !isRecording, + modifier = Modifier + .padding(end = 24.dp) + .size(56.dp) + ) { + 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/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/screens/ARScreen.kt b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ARScreen.kt index 49f2904..6982d71 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,36 +1,53 @@ 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.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.* @@ -50,6 +67,10 @@ 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 = {}, modifier: Modifier = Modifier ) { // CRITICAL FIX: Use rememberUpdatedState to ensure callbacks always capture latest state @@ -109,17 +130,23 @@ fun ARScreen( .padding(paddingValues) ) { val density = LocalDensity.current - val trashZoneSize = 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 trashZoneSizePx = with(density) { trashZoneSize.toPx() } + 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: check both X and Y coordinates + // 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 - trashZoneSizePx - trashZonePaddingPx - val trashTop = screenHeightPx - trashZoneSizePx - trashZonePaddingPx + val trashLeft = screenWidthPx - trashZoneWidthPx - trashZonePaddingPx + val trashTop = screenHeightPx - trashZoneHeightPx - trashZonePaddingPx return screenX >= trashLeft && screenY >= trashTop } @@ -225,50 +252,57 @@ fun ARScreen( ) } - // Selected object indicator - compact chip style - uiState.selectedObjectId?.let { selectedId -> - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 80.dp, start = 16.dp, end = 16.dp), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), - tonalElevation = 2.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + // 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 ) { - Icon( - imageVector = Icons.Default.ViewInAr, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - Column { - Text( - text = selectedObject?.name ?: "${stringResource(Res.string.selected)}: ${selectedId.take(8)}…", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = stringResource(Res.string.long_press_to_place), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - // Subtle cancel button - IconButton( - onClick = { onSelectObject(null) }, - modifier = Modifier.size(24.dp) + 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), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + imageVector = Icons.Default.ViewInAr, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.White ) + 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) + ) + } } } } @@ -280,6 +314,68 @@ fun ARScreen( 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 + ) + } + + // Camera Controls Bar (bottom) + CameraControlsBar( + isRecording = uiState.isRecording, + onCapturePhoto = onCapturePhoto, + onToggleRecording = onToggleRecording, + onOpenGallery = onOpenGallery, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + ) + + // 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 @@ -383,17 +479,32 @@ fun TrashZone( Box( modifier = modifier .padding(16.dp) - .size(80.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( color = if (isHovered) - MaterialTheme.colorScheme.error.copy(alpha = 0.9f) + 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.onErrorContainer.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp) ), contentAlignment = Alignment.Center ) { Column( + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -406,14 +517,16 @@ fun TrashZone( MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(if (isHovered) 32.dp else 28.dp) ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(6.dp)) Text( 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() ) } } @@ -504,13 +617,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, @@ -533,6 +648,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, @@ -615,3 +763,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..a022934 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/GalleryScreen.kt @@ -0,0 +1,598 @@ +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) + ) { + items(items, key = { it.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..f6d9cf4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/trendhive/arsample/presentation/ui/screens/ObjectGalleryScreen.kt @@ -0,0 +1,490 @@ +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.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 -> { + 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 +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..d7bad33 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,10 @@ 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.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 +21,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 +55,12 @@ 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 ) class ARViewModel( @@ -38,7 +69,9 @@ 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 ) : androidx.lifecycle.ViewModel() { companion object { @@ -50,6 +83,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() @@ -226,7 +262,7 @@ class ARViewModel( moveObject(currentState.objectId, currentState.currentPosition) } } - else -> { /* No action needed */ } + else -> { /* No action needed for other states */ } } resetDragState() } @@ -272,4 +308,177 @@ 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 + } + + viewModelScope.launch { + capturePhotoUseCase.invoke(imageData).fold( + onSuccess = { photo -> + _uiState.value = _uiState.value.copy( + captureState = CaptureState.Success("Photo saved") + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + captureState = CaptureState.Error(e.message ?: "Failed to save photo") + ) + } + ) + } + } + + /** + * 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..20620d0 --- /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 +) : androidx.lifecycle.ViewModel() { + + 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/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/ModelPreviewThumbnail.ios.kt b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.ios.kt new file mode 100644 index 0000000..ef96e7e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/trendhive/arsample/presentation/ui/components/ModelPreviewThumbnail.ios.kt @@ -0,0 +1,116 @@ +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.MaterialTheme +import androidx.compose.runtime.Composable +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.PathFillType +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 + +/** + * iOS implementation of 3D model preview thumbnail. + * + * Since SceneView is not available on iOS, this shows a placeholder + * ViewInAr icon styled to match the preview aesthetic. + * + * Future enhancement: Could use RealityKit via interop for actual 3D preview. + */ +@Composable +actual fun ModelPreviewThumbnail( + modelPath: String, + modifier: Modifier, + autoRotate: Boolean +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + // Use custom ViewInAr icon (same as AppIcons pattern) + androidx.compose.material3.Icon( + imageVector = ViewInArIconPreview, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + ) + } +} + +/** + * Custom ViewInAr icon for iOS (following AppIcons.ios.kt pattern) + */ +private val ViewInArIconPreview: ImageVector + get() = ImageVector.Builder( + name = "ViewInAr", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + // Main 3D cube outline + path(fill = SolidColor(Color.Black)) { + // Cube front face + moveTo(3f, 4f) + lineTo(3f, 10f) + lineTo(5f, 10f) + lineTo(5f, 6f) + lineTo(9f, 6f) + lineTo(9f, 4f) + close() + + // Cube back corner (top-right) + moveTo(15f, 4f) + lineTo(15f, 6f) + lineTo(19f, 6f) + lineTo(19f, 10f) + lineTo(21f, 10f) + lineTo(21f, 4f) + close() + + // Cube front corner (bottom-left) + moveTo(3f, 14f) + lineTo(3f, 20f) + lineTo(9f, 20f) + lineTo(9f, 18f) + lineTo(5f, 18f) + lineTo(5f, 14f) + close() + + // Cube back corner (bottom-right) + moveTo(15f, 18f) + lineTo(15f, 20f) + lineTo(21f, 20f) + lineTo(21f, 14f) + lineTo(19f, 14f) + lineTo(19f, 18f) + close() + + // Center 3D shape + moveTo(12f, 8f) + lineTo(8f, 10.5f) + lineTo(8f, 15.5f) + lineTo(12f, 18f) + lineTo(16f, 15.5f) + lineTo(16f, 10.5f) + close() + + // Inner highlight + 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/docs/INDEX.md b/docs/INDEX.md index 3773e74..f7cf1ea 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,201 +2,139 @@ > **AR Sample Application** - Kotlin Multiplatform app for importing and placing 3D objects in AR scenes (Android ARCore + iOS ARKit) -## πŸ“‹ Quick Links +## Quick Links | Document | Description | |----------|-------------| | [README.md](../README.md) | Project overview and getting started | | [CLAUDE.md](../CLAUDE.md) | AI assistant instructions and architecture | -| [.github/copilot-instructions.md](../.github/copilot-instructions.md) | GitHub Copilot instructions | +| [CHANGELOG.md](../CHANGELOG.md) | Version history | --- -## πŸ“‚ Documentation Structure +## Documentation Structure -### 🎨 UI/UX Design -Complete design system and guidelines for ARSample application. +### Architecture | File | Description | |------|-------------| -| [design/README.md](./design/README.md) | **πŸ“š START HERE** - Design documentation overview | -| [design/UI_UX_DESIGN_GUIDE.md](./design/UI_UX_DESIGN_GUIDE.md) | **πŸ“– Complete Guide** - Material 3 + iOS HIG design system (1000+ lines) | -| [design/AR_COMPETITOR_ANALYSIS.md](./design/AR_COMPETITOR_ANALYSIS.md) | **πŸ” Research** - Industry analysis (IKEA, Amazon, Houzz, Pokemon GO) | -| [design/DESIGN_TOKENS.md](./design/DESIGN_TOKENS.md) | **⚑ Quick Ref** - Colors, typography, spacing, icons | +| [architecture/technical-analysis.md](./architecture/technical-analysis.md) | Technical architecture analysis and decisions | --- -### πŸ—οΈ Architecture -Documentation about system design and architecture. +### Design (UI/UX) | File | Description | |------|-------------| -| [technical-analysis.md](./architecture/technical-analysis.md) | Technical architecture analysis and decisions | -| **[DRAG_DROP_DESIGN.md](./DRAG_DROP_DESIGN.md)** | **πŸ†• NEW** - Drag-to-move and drag-to-delete feature design | +| [design/README.md](./design/README.md) | Design documentation overview | +| [design/UI_UX_DESIGN_GUIDE.md](./design/UI_UX_DESIGN_GUIDE.md) | Material 3 + iOS HIG design system | +| [design/AR_COMPETITOR_ANALYSIS.md](./design/AR_COMPETITOR_ANALYSIS.md) | Industry analysis (IKEA, Amazon, Houzz) | +| [design/DESIGN_TOKENS.md](./design/DESIGN_TOKENS.md) | Colors, typography, spacing, icons | +| [design/drag-drop-design.md](./design/drag-drop-design.md) | Drag-to-move and drag-to-delete feature design | +| [design/app-icon/README.md](./design/app-icon/README.md) | App icon design documentation | +| [design/app-icon/SUMMARY.md](./design/app-icon/SUMMARY.md) | App icon package summary | +| [design/app-icon/color-palette.md](./design/app-icon/color-palette.md) | App icon color palette | +| [design/app-icon/android-adaptive-guide.md](./design/app-icon/android-adaptive-guide.md) | Android adaptive icon guide | +| [design/app-icon/ios-integration-guide.md](./design/app-icon/ios-integration-guide.md) | iOS icon integration guide | +| [design/splash/README.md](./design/splash/README.md) | Splash screen documentation | +| [design/splash/SPLASH_SCREEN_DESIGN_SPEC.md](./design/splash/SPLASH_SCREEN_DESIGN_SPEC.md) | Splash screen design spec | --- -### πŸ‘₯ Agents -Multi-agent system documentation for development workflow. +### Android (ARCore) | File | Description | |------|-------------| -| [../.claude/agents/README.md](../.claude/agents/README.md) | Agent system overview and workflow | -| [../.claude/agents/design-analysis-agent.md](../.claude/agents/design-analysis-agent.md) | Research and architecture design agent | -| [../.claude/agents/android-expert-agent.md](../.claude/agents/android-expert-agent.md) | ARCore implementation expert | -| [../.claude/agents/ios-expert-agent.md](../.claude/agents/ios-expert-agent.md) | ARKit implementation expert | -| [../.claude/agents/main-developer-agent.md](../.claude/agents/main-developer-agent.md) | Core development agent (Domain/Data/Presentation) | -| [../.claude/agents/bug-fixer-agent.md](../.claude/agents/bug-fixer-agent.md) | Debugging and bug fixing agent | -| [../.claude/agents/test-developer-agent.md](../.claude/agents/test-developer-agent.md) | Unit testing agent | -| [../.claude/agents/code-reviewer-agent.md](../.claude/agents/code-reviewer-agent.md) | Code quality control agent | +| [guides/arcore-quick-reference.md](./guides/arcore-quick-reference.md) | ARCore patterns and gotchas quick reference | +| [guides/arcore-best-practices.md](./guides/arcore-best-practices.md) | ARCore best practices cheatsheet | +| [guides/hit-testing/README.md](./guides/hit-testing/README.md) | Hit testing documentation hub | +| [guides/hit-testing/android-arcore-analysis.md](./guides/hit-testing/android-arcore-analysis.md) | ARCore hit testing analysis and fixes | +| [guides/hit-testing/implementation.md](./guides/hit-testing/implementation.md) | Hit testing implementation guide | +| [guides/hit-testing/quick-reference.md](./guides/hit-testing/quick-reference.md) | Hit testing quick reference | +| [guides/hit-testing/design.md](./guides/hit-testing/design.md) | Hit testing design document | +| [reports/android-arcore-summary.md](./reports/android-arcore-summary.md) | Executive summary of Android implementation | +| [reports/android-arcore-state-sync-fix.md](./reports/android-arcore-state-sync-fix.md) | AndroidView stale closure fix | +| [reports/android-expert-session-summary.md](./reports/android-expert-session-summary.md) | Android expert session documentation | +| [reports/android-fix-complete.md](./reports/android-fix-complete.md) | Android fix completion report | --- -### πŸ“± Platform Specific - -#### Android (ARCore) -Android platform-specific documentation (ARCore, SceneView, Kotlin). +### iOS (ARKit) | File | Description | |------|-------------| -| **[ANDROID_ARCORE_STATE_SYNC_FIX.md](./ANDROID_ARCORE_STATE_SYNC_FIX.md)** | **πŸ”΄ FIXED** - AndroidView stale closure fix with rememberUpdatedState | -| [ARCORE_QUICK_REFERENCE.md](./ARCORE_QUICK_REFERENCE.md) | **πŸ“š Quick Reference** - Patterns, templates, gotchas for AR development | -| [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) | **πŸš€ Deployment** - Build, test, and deployment instructions | -| [ANDROID_EXPERT_SESSION_SUMMARY.md](./ANDROID_EXPERT_SESSION_SUMMARY.md) | **πŸ“‹ Session Summary** - Complete analysis and solution documentation | -| [android-arcore-analysis.md](./guides/hit-testing/android-arcore-analysis.md) | ARCore hit testing analysis and fixes | -| [android-arcore-summary.md](./android-arcore-summary.md) | Executive summary of Android implementation issues | -| [arcore-best-practices-cheatsheet.md](./arcore-best-practices-cheatsheet.md) | Quick reference for ARCore best practices | - -#### iOS (ARKit) -iOS platform-specific documentation (ARKit, RealityKit, Swift). - -| File | Description | -|------|-------------| -| [ios/ios-arkit-quick-fix-guide.md](./ios/ios-arkit-quick-fix-guide.md) | **πŸ”₯ START HERE** - 3-4 saat iΓ§inde critical issues dΓΌzelt | -| [ios/ios-arkit-hit-testing-report.md](./ios/ios-arkit-hit-testing-report.md) | **πŸ“š COMPREHENSIVE** - iOS ARKit hit testing kapsamlΔ± implementation raporu (1000+ satΔ±r) | +| [ios/ios-arkit-quick-fix-guide.md](./ios/ios-arkit-quick-fix-guide.md) | Critical iOS issues quick fix guide | +| [ios/ios-arkit-hit-testing-report.md](./ios/ios-arkit-hit-testing-report.md) | iOS ARKit hit testing comprehensive report | | [ios/ios-expert-report.md](./ios/ios-expert-report.md) | Comprehensive iOS ARKit implementation report | | [ios/ios-expert-summary.md](./ios/ios-expert-summary.md) | iOS implementation summary | | [ios/ios-implementation-checklist.md](./ios/ios-implementation-checklist.md) | iOS implementation checklist | | [ios/ios-implementation-code-examples.md](./ios/ios-implementation-code-examples.md) | iOS code examples and snippets | | [ios/ios-quick-reference.md](./ios/ios-quick-reference.md) | Quick reference for iOS implementation | +| [ios/ios-issues-analysis.md](./ios/ios-issues-analysis.md) | iOS issues analysis | +| [ios/ios-workflow-readme.md](./ios/ios-workflow-readme.md) | iOS CI/CD workflow documentation | --- -### πŸ“– Guides -Step-by-step guides and checklists. +### Guides | File | Description | |------|-------------| -| [guides/test-implementation-guide.md](./guides/test-implementation-guide.md) | Guide for implementing unit tests | +| [guides/test-implementation-guide.md](./guides/test-implementation-guide.md) | Unit test implementation guide | | [guides/code-review-checklist.md](./guides/code-review-checklist.md) | Code review checklist and standards | -| **[guides/hit-testing/README.md](./guides/hit-testing/README.md)** | **πŸ“˜ NEW** - Comprehensive hit testing documentation hub | +| [guides/deployment-guide.md](./guides/deployment-guide.md) | Build, test, and deployment instructions | +| [guides/ios-ci-quick-reference.md](./guides/ios-ci-quick-reference.md) | iOS CI/CD quick reference | --- -### πŸ“Š Reports -Development reports, summaries, and status updates. +### Bugs | File | Description | |------|-------------| -| [reports/code-fixes-index.md](./reports/code-fixes-index.md) | Complete code fixes index and verification | -| [reports/implementation-complete.md](./reports/implementation-complete.md) | Implementation completion report | -| [reports/changes-reference.md](./reports/changes-reference.md) | Reference of all code changes made | -| [reports/code-fixes-summary.md](./reports/code-fixes-summary.md) | Summary of bug fixes and improvements | -| [reports/test-coverage-report.md](./reports/test-coverage-report.md) | Test coverage metrics and analysis | -| [reports/test-files-summary.md](./reports/test-files-summary.md) | Summary of all test files | - ---- - -## πŸ—ΊοΈ Navigation by Topic - -### Getting Started -1. [README.md](../README.md) - Start here -2. [CLAUDE.md](../CLAUDE.md) - Understand the architecture -3. [../.claude/agents/README.md](../.claude/agents/README.md) - Learn about the development workflow - -### Architecture & Design -1. [architecture/technical-analysis.md](./architecture/technical-analysis.md) -2. [../.claude/agents/design-analysis-agent.md](../.claude/agents/design-analysis-agent.md) -3. [CLAUDE.md](../CLAUDE.md) - Key patterns section - -### Platform Implementation - -**Android (ARCore):** -- πŸ”΄ **[guides/hit-testing/android-arcore-analysis.md](./guides/hit-testing/android-arcore-analysis.md)** - Critical implementation issues -- [android-arcore-summary.md](./android-arcore-summary.md) - Executive summary -- [arcore-best-practices-cheatsheet.md](./arcore-best-practices-cheatsheet.md) - Quick reference -- [../.claude/agents/android-expert-agent.md](../.claude/agents/android-expert-agent.md) - Agent documentation - -**iOS (ARKit):** -- [ios/ios-arkit-hit-testing-report.md](./ios/ios-arkit-hit-testing-report.md) - Hit testing implementation -- [ios/ios-expert-report.md](./ios/ios-expert-report.md) - Comprehensive report -- [ios/ios-implementation-code-examples.md](./ios/ios-implementation-code-examples.md) - Code examples -- [ios/ios-quick-reference.md](./ios/ios-quick-reference.md) - Quick reference - -### Development Workflow -1. [../.claude/agents/main-developer-agent.md](../.claude/agents/main-developer-agent.md) - Core development -2. [../.claude/agents/test-developer-agent.md](../.claude/agents/test-developer-agent.md) - Testing -3. [../.claude/agents/code-reviewer-agent.md](../.claude/agents/code-reviewer-agent.md) - Code review -4. [../.claude/agents/bug-fixer-agent.md](../.claude/agents/bug-fixer-agent.md) - Bug fixing - -### Testing -1. [guides/TEST_IMPLEMENTATION_GUIDE.md](./guides/TEST_IMPLEMENTATION_GUIDE.md) -2. [reports/TEST_COVERAGE_REPORT.md](./reports/TEST_COVERAGE_REPORT.md) -3. [reports/TEST_FILES_SUMMARY.md](./reports/TEST_FILES_SUMMARY.md) - -### Quality Assurance -1. [guides/CODE_REVIEW_CHECKLIST.md](./guides/CODE_REVIEW_CHECKLIST.md) -2. [agents/code-reviewer-agent.md](./agents/code-reviewer-agent.md) -3. [reports/CODE_FIXES_SUMMARY.md](./reports/CODE_FIXES_SUMMARY.md) +| [bugs/BUG-001-import-feature-not-working.md](./bugs/BUG-001-import-feature-not-working.md) | BUG-001 import feature analysis | +| [bugs/bug-001-ar-placement-fix.md](./bugs/bug-001-ar-placement-fix.md) | BUG-001 AR placement fix | +| [bugs/bug-001-verification.md](./bugs/bug-001-verification.md) | BUG-001 fix verification | --- -## πŸ“ Document Status +### Reports -| Category | File Count | Status | -|----------|------------|--------| -| Architecture | 1 | βœ… Complete | -| Agents | 8 | βœ… Complete | -| Android Docs | 7 | βœ… State Sync Fixed | -| iOS Docs | 6 | βœ… Complete | -| Guides | 4 | βœ… Complete | -| Reports | 6 | βœ… Complete | -| **Total** | **32** | 🟒 Android Fix Deployed | +| File | Description | +|------|-------------| +| [reports/implementation-complete.md](./reports/implementation-complete.md) | Implementation completion report | +| [reports/changes-reference.md](./reports/changes-reference.md) | Reference of all code changes | +| [reports/code-fixes-summary.md](./reports/code-fixes-summary.md) | Bug fixes and improvements summary | +| [reports/code-fixes-index.md](./reports/code-fixes-index.md) | Complete code fixes index | +| [reports/test-coverage-report.md](./reports/test-coverage-report.md) | Test coverage metrics | +| [reports/test-files-summary.md](./reports/test-files-summary.md) | Summary of all test files | +| [reports/test-summary.md](./reports/test-summary.md) | ModelUri bug fix test suite summary | +| [reports/fix-drag-delete.md](./reports/fix-drag-delete.md) | Drag-delete fix report | +| [reports/DDD_STRUCTURE_UPDATE.md](./reports/DDD_STRUCTURE_UPDATE.md) | DDD structure update report | +| [reports/DDD_STRUCTURE_COMPARISON.md](./reports/DDD_STRUCTURE_COMPARISON.md) | DDD structure comparison | +| [reports/ANDROID_SPLASH_IMPLEMENTATION_REPORT.md](./reports/ANDROID_SPLASH_IMPLEMENTATION_REPORT.md) | Android splash screen implementation report | +| [reports/ios-workflow-implementation-summary.md](./reports/ios-workflow-implementation-summary.md) | iOS workflow implementation summary | +| [reports/i18n-implementation-report.md](./reports/i18n-implementation-report.md) | i18n implementation report | +| [reports/i18n-build-fix-report.md](./reports/i18n-build-fix-report.md) | i18n build fix report | --- -## πŸ”„ Recent Updates - -- **2026-03-30**: πŸ†• **[Drag-and-Drop Design Document](./DRAG_DROP_DESIGN.md)** - Complete UX/Architecture design for drag-to-move and drag-to-delete -- **2024-04-02**: βœ… **Android State Sync Bug FIXED** - rememberUpdatedState solution implemented -- **2024-04-02**: Added 4 new Android documentation files (fix analysis, quick reference, deployment guide, session summary) -- **2024-03-30**: πŸ”΄ **Android ARCore critical analysis completed** - 5 major issues found in hit testing -- **2024-03-30**: Added Android ARCore implementation documentation (3 new files) -- **2024-03-31**: Documentation reorganized into `docs/` folder structure -- **2024-03-31**: Agents updated with Flutter-inspired DDD patterns -- **2024-03-31**: Added Value Objects, DTO/Mapper patterns, Base Classes -- **2024-03-30**: Initial agent system created - -## ⚠️ Action Required - -### πŸ”΄ Android Critical Issues -The Android ARCore implementation has **5 critical issues** that must be fixed before production: +### Reviews -1. **Missing Anchor usage** - Models not anchored properly -2. **No hit result filtering** - Distance/confidence checks missing -3. **No plane tracking state checks** - Can place on invalid planes -4. **No pose polygon validation** - Models can float in air -5. **DepthPoint not prioritized** - Missing most accurate placement - -**Estimated fix time:** 5 hours (Sprint 1) -**See:** [ANDROID_ARCORE_HIT_TEST_ANALYSIS.md](./ANDROID_ARCORE_HIT_TEST_ANALYSIS.md) for detailed fixes +| File | Description | +|------|-------------| +| [reviews/README.md](./reviews/README.md) | Reviews overview | +| [reviews/DESIGN_REVIEW_SUMMARY.md](./reviews/DESIGN_REVIEW_SUMMARY.md) | Design review summary | +| [reviews/DESIGN_DOCS_REVIEW_2026-04-05.md](./reviews/DESIGN_DOCS_REVIEW_2026-04-05.md) | Design docs review | +| [reviews/APP_ICON_INTEGRATION_REVIEW_2026-04-05.md](./reviews/APP_ICON_INTEGRATION_REVIEW_2026-04-05.md) | App icon integration review | +| [reviews/SPLASH_REVIEW_SUMMARY.md](./reviews/SPLASH_REVIEW_SUMMARY.md) | Splash screen review summary | +| [reviews/SPLASH_SCREENS_REVIEW_2026-04-05.md](./reviews/SPLASH_SCREENS_REVIEW_2026-04-05.md) | Splash screens review | +| [reviews/ACTION_ITEMS_DESIGN_ANALYSIS_AGENT.md](./reviews/ACTION_ITEMS_DESIGN_ANALYSIS_AGENT.md) | Design analysis agent action items | --- -## πŸ“š External References +## Navigation by Role -- [Kotlin Multiplatform Documentation](https://kotlinlang.org/docs/multiplatform.html) -- [ARCore Developer Guide](https://developers.google.com/ar) -- [ARKit Documentation](https://developer.apple.com/documentation/arkit) -- [Clean Architecture by Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) -- [Domain-Driven Design Reference](https://www.domainlanguage.com/ddd/reference/) +- **Developers**: [CLAUDE.md](../CLAUDE.md) β†’ [architecture/technical-analysis.md](./architecture/technical-analysis.md) β†’ [guides/](./guides/) +- **Android Devs**: [guides/arcore-quick-reference.md](./guides/arcore-quick-reference.md) β†’ [guides/hit-testing/](./guides/hit-testing/) +- **iOS Devs**: [ios/ios-quick-reference.md](./ios/ios-quick-reference.md) β†’ [ios/ios-arkit-hit-testing-report.md](./ios/ios-arkit-hit-testing-report.md) +- **Testers**: [guides/test-implementation-guide.md](./guides/test-implementation-guide.md) β†’ [reports/test-coverage-report.md](./reports/test-coverage-report.md) +- **Reviewers**: [guides/code-review-checklist.md](./guides/code-review-checklist.md) diff --git a/docs/README.md b/docs/README.md index a8e7974..7061e1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,44 +2,40 @@ All ARSample project documentation is organized here. -## πŸ“‚ Folder Structure +## Folder Structure ``` docs/ β”œβ”€β”€ INDEX.md # Master documentation index (start here!) β”œβ”€β”€ architecture/ # System design and technical analysis -β”œβ”€β”€ agents/ # Multi-agent development system -β”œβ”€β”€ ios/ # iOS-specific documentation -β”œβ”€β”€ guides/ # How-to guides and checklists -└── reports/ # Status reports and summaries +β”œβ”€β”€ bugs/ # Bug reports and fixes +β”œβ”€β”€ design/ # UI/UX design system and guidelines +β”œβ”€β”€ guides/ # How-to guides and checklists +β”œβ”€β”€ ios/ # iOS-specific documentation +β”œβ”€β”€ reports/ # Status reports and summaries +└── reviews/ # Code and design review reports ``` -## πŸš€ Getting Started - -**New to the project?** Start here: +## Getting Started 1. **[INDEX.md](./INDEX.md)** - Complete documentation index -2. **[../CLAUDE.md](../CLAUDE.md)** - Architecture overview -3. **[agents/README.md](./agents/README.md)** - Development workflow +2. **[../CLAUDE.md](../CLAUDE.md)** - Architecture overview and key patterns +3. **[../.claude/agents/README.md](../.claude/agents/README.md)** - Development workflow -## πŸ“š Quick Links +## Quick Links ### For Developers -- [Main Developer Agent](./agents/main-developer-agent.md) -- [Architecture Guide](./architecture/TECHNICAL_ANALYSIS.md) +- [Architecture Guide](./architecture/technical-analysis.md) +- [ARCore Quick Reference](./guides/arcore-quick-reference.md) +- [Deployment Guide](./guides/deployment-guide.md) ### For Testers -- [Test Implementation Guide](./guides/TEST_IMPLEMENTATION_GUIDE.md) -- [Test Coverage Report](./reports/TEST_COVERAGE_REPORT.md) +- [Test Implementation Guide](./guides/test-implementation-guide.md) +- [Test Coverage Report](./reports/test-coverage-report.md) ### For Reviewers -- [Code Review Checklist](./guides/CODE_REVIEW_CHECKLIST.md) -- [Code Reviewer Agent](./agents/code-reviewer-agent.md) +- [Code Review Checklist](./guides/code-review-checklist.md) ### Platform-Specific - [iOS Documentation](./ios/) -- [Android Expert Agent](./agents/android-expert-agent.md) - ---- - -**πŸ“– For the complete navigation guide, see [INDEX.md](./INDEX.md)** +- [Android Hit Testing](./guides/hit-testing/) diff --git a/docs/BUG-001-AR-PLACEMENT-FIX.md b/docs/bugs/bug-001-ar-placement-fix.md similarity index 100% rename from docs/BUG-001-AR-PLACEMENT-FIX.md rename to docs/bugs/bug-001-ar-placement-fix.md diff --git a/docs/BUG-001-VERIFICATION.md b/docs/bugs/bug-001-verification.md similarity index 100% rename from docs/BUG-001-VERIFICATION.md rename to docs/bugs/bug-001-verification.md diff --git a/docs/design/app-icon/INDEX.md b/docs/design/app-icon/INDEX.md deleted file mode 100644 index 5c79c1e..0000000 --- a/docs/design/app-icon/INDEX.md +++ /dev/null @@ -1,100 +0,0 @@ -# App Icon Design - Quick Index - -**Status:** βœ… Ready for Integration -**Version:** 1.0 -**Date:** 2026-03-30 - ---- - -## πŸ“‹ Quick Links - -| Document | Description | Size | -|----------|-------------|------| -| **[SUMMARY.md](SUMMARY.md)** | **πŸ“Œ START HERE** - Complete overview and quick start | 11 KB | -| [README.md](README.md) | Comprehensive design documentation | 8.9 KB | -| [preview.html](preview.html) | 🎨 Visual preview gallery (open in browser) | 16 KB | - ---- - -## 🎨 Design Files - -| File | Purpose | -|------|---------| -| [icon-master.svg](icon-master.svg) | Master design (1024x1024) - All platforms | -| [android-foreground.svg](android-foreground.svg) | Android adaptive icon foreground layer | -| [android-background.svg](android-background.svg) | Android adaptive icon background layer | - ---- - -## πŸ“š Documentation - -| Guide | Platform | Size | -|-------|----------|------| -| [android-adaptive-guide.md](android-adaptive-guide.md) | πŸ€– Android 8.0+ | 10 KB | -| [ios-integration-guide.md](ios-integration-guide.md) | 🍎 iOS 13+ | 12 KB | -| [color-palette.md](color-palette.md) | All platforms | 6.1 KB | - ---- - -## πŸ› οΈ Scripts - -| Script | Purpose | Status | -|--------|---------|--------| -| [export-script.sh](export-script.sh) | Export all iOS + Android sizes | βœ… Executable | -| [copy-ios-icons.sh](copy-ios-icons.sh) | Copy iOS icons to asset catalog | βœ… Executable | - ---- - -## πŸš€ Quick Start - -### 1. Export All Sizes - -```bash -./export-script.sh -``` - -### 2. Copy to iOS Project - -```bash -./copy-ios-icons.sh -``` - -### 3. Android Integration - -See [android-adaptive-guide.md](android-adaptive-guide.md) for adaptive icon setup. - ---- - -## πŸ“ Export Folders - -After running export scripts: - -- `ios/` - 11 PNG files for iOS asset catalog -- `android/` - 5 legacy icons + 5 adaptive foreground layers - ---- - -## 🎯 Use Cases - -| Task | Document | -|------|----------| -| First time setup | [SUMMARY.md](SUMMARY.md) | -| Visual preview | [preview.html](preview.html) | -| Android integration | [android-adaptive-guide.md](android-adaptive-guide.md) | -| iOS integration | [ios-integration-guide.md](ios-integration-guide.md) | -| Color reference | [color-palette.md](color-palette.md) | -| Complete documentation | [README.md](README.md) | - ---- - -## πŸ“Š Design Stats - -- **Theme:** AR-powered 3D placement -- **Primary Element:** Isometric cube -- **Colors:** Purple-indigo gradient + white cube -- **Formats:** SVG (source), PNG (exports) -- **Platform Support:** iOS 13+, Android 8.0+ - ---- - -**πŸ“– For comprehensive documentation, see [SUMMARY.md](SUMMARY.md)** diff --git a/docs/DRAG_DROP_DESIGN.md b/docs/design/drag-drop-design.md similarity index 100% rename from docs/DRAG_DROP_DESIGN.md rename to docs/design/drag-drop-design.md diff --git a/docs/documentation-organization-complete.md b/docs/documentation-organization-complete.md deleted file mode 100644 index b576d48..0000000 --- a/docs/documentation-organization-complete.md +++ /dev/null @@ -1,269 +0,0 @@ -# Documentation Organization Complete βœ… - -**Date:** 2026-03-31 -**Status:** Complete - ---- - -## πŸ“Š Summary - -All markdown documentation has been successfully organized into a structured folder hierarchy. - -### Statistics - -| Metric | Count | -|--------|-------| -| Total MD Files | 27 | -| Files in `docs/` | 24 | -| Root MD Files | 3 | -| Categories | 5 | - ---- - -## πŸ“‚ New Structure - -``` -ARSample/ -β”œβ”€β”€ INDEX.md # Master hub (NEW) -β”œβ”€β”€ README.md # Project overview -β”œβ”€β”€ CLAUDE.md # AI assistant config (UPDATED) -β”œβ”€β”€ .github/ -β”‚ └── copilot-instructions.md # GitHub Copilot (UPDATED) -β”‚ -└── docs/ # Documentation folder (NEW) - β”œβ”€β”€ INDEX.md # Complete doc index (NEW) - β”œβ”€β”€ README.md # Docs quick start (NEW) - β”‚ - β”œβ”€β”€ architecture/ # Architecture docs - β”‚ └── TECHNICAL_ANALYSIS.md - β”‚ - β”œβ”€β”€ agents/ # Multi-agent system (UPDATED) - β”‚ β”œβ”€β”€ README.md - β”‚ β”œβ”€β”€ design-analysis-agent.md ⭐ UPDATED - β”‚ β”œβ”€β”€ android-expert-agent.md - β”‚ β”œβ”€β”€ ios-expert-agent.md - β”‚ β”œβ”€β”€ main-developer-agent.md ⭐ UPDATED - β”‚ β”œβ”€β”€ bug-fixer-agent.md - β”‚ β”œβ”€β”€ test-developer-agent.md ⭐ UPDATED - β”‚ └── code-reviewer-agent.md ⭐ UPDATED - β”‚ - β”œβ”€β”€ ios/ # iOS-specific docs - β”‚ β”œβ”€β”€ ios-expert-report.md - β”‚ β”œβ”€β”€ ios-expert-summary.md - β”‚ β”œβ”€β”€ ios-implementation-checklist.md - β”‚ β”œβ”€β”€ ios-implementation-code-examples.md - β”‚ └── ios-quick-reference.md - β”‚ - β”œβ”€β”€ guides/ # How-to guides - β”‚ β”œβ”€β”€ CODE_REVIEW_CHECKLIST.md - β”‚ └── TEST_IMPLEMENTATION_GUIDE.md - β”‚ - └── reports/ # Status reports - β”œβ”€β”€ CODE_FIXES_INDEX.md - β”œβ”€β”€ IMPLEMENTATION_COMPLETE.md - β”œβ”€β”€ CHANGES_REFERENCE.md - β”œβ”€β”€ CODE_FIXES_SUMMARY.md - β”œβ”€β”€ TEST_COVERAGE_REPORT.md - └── TEST_FILES_SUMMARY.md -``` - ---- - -## 🎯 Key Improvements - -### 1. Clear Entry Points -- **INDEX.md** (root) - Main documentation hub -- **docs/INDEX.md** - Detailed documentation index -- **docs/README.md** - Quick navigation guide - -### 2. Organized by Category -- **architecture/** - System design and technical analysis -- **agents/** - Multi-agent development system -- **ios/** - iOS-specific documentation -- **guides/** - Step-by-step guides -- **reports/** - Status and progress reports - -### 3. Updated Content -- βœ… Agent files updated with Flutter-inspired DDD patterns -- βœ… CLAUDE.md updated with key patterns -- βœ… GitHub Copilot instructions enhanced -- βœ… Cross-references maintained - ---- - -## πŸ“š Documentation Index - -### Root Level (Quick Access) -| File | Purpose | -|------|---------| -| `INDEX.md` | Master documentation hub with quick links | -| `README.md` | Project overview, setup, and build commands | -| `CLAUDE.md` | AI assistant config, architecture, key patterns | - -### docs/ Level (Organized Content) -| Folder | File Count | Content | -|--------|------------|---------| -| `architecture/` | 1 | Technical analysis and design decisions | -| `agents/` | 8 | Multi-agent development system | -| `ios/` | 5 | iOS ARKit implementation docs | -| `guides/` | 2 | How-to guides and checklists | -| `reports/` | 6 | Status reports and summaries | - ---- - -## πŸ” Navigation Patterns - -### By Role -- **Developers** β†’ `docs/agents/main-developer-agent.md` -- **Testers** β†’ `docs/guides/TEST_IMPLEMENTATION_GUIDE.md` -- **Reviewers** β†’ `docs/guides/CODE_REVIEW_CHECKLIST.md` -- **iOS Devs** β†’ `docs/ios/ios-quick-reference.md` -- **Android Devs** β†’ `docs/agents/android-expert-agent.md` - -### By Topic -- **Architecture** β†’ `docs/architecture/TECHNICAL_ANALYSIS.md` -- **Agents System** β†’ `docs/agents/README.md` -- **iOS Docs** β†’ `docs/ios/` -- **Testing** β†’ `docs/guides/TEST_IMPLEMENTATION_GUIDE.md` -- **Status** β†’ `docs/reports/` - -### By Task -- **Getting Started** β†’ `README.md` β†’ `CLAUDE.md` β†’ `docs/INDEX.md` -- **Understanding Architecture** β†’ `docs/architecture/` β†’ `CLAUDE.md` -- **Learning Workflow** β†’ `docs/agents/README.md` β†’ agent files -- **Platform Implementation** β†’ `docs/ios/` or `docs/agents/android-expert-agent.md` - ---- - -## ✨ New Features - -### 1. Master Documentation Hub (INDEX.md) -- Beautiful badges and visual hierarchy -- Quick start section -- Architecture overview -- Multi-agent system summary -- Build commands -- Status dashboard -- Documentation categories by topic and role - -### 2. Documentation Index (docs/INDEX.md) -- Complete file listing with descriptions -- Organized by category -- Navigation by topic -- Document status tracker -- External references - -### 3. Quick Start Guide (docs/README.md) -- Fast navigation for specific roles -- Links to most relevant docs -- Clear folder structure - ---- - -## πŸ”— Cross-References - -All documents maintain proper cross-references: -- βœ… Root INDEX.md β†’ docs/INDEX.md -- βœ… docs/INDEX.md β†’ All category files -- βœ… Agent files β†’ Each other and domain docs -- βœ… Guides β†’ Reports and agents -- βœ… Reports β†’ Implementation files - ---- - -## πŸ“ˆ Before vs After - -### Before -``` -ARSample/ -β”œβ”€β”€ (26 .md files scattered in root) -β”œβ”€β”€ .claude/agents/ (7 agent files) -└── .github/copilot-instructions.md -``` -**Problem:** Hard to find, no organization, cluttered root - -### After -``` -ARSample/ -β”œβ”€β”€ INDEX.md (hub) -β”œβ”€β”€ README.md -β”œβ”€β”€ CLAUDE.md -β”œβ”€β”€ .github/copilot-instructions.md -└── docs/ (organized structure) - β”œβ”€β”€ INDEX.md - β”œβ”€β”€ README.md - β”œβ”€β”€ architecture/ (1) - β”œβ”€β”€ agents/ (8) - β”œβ”€β”€ ios/ (5) - β”œβ”€β”€ guides/ (2) - └── reports/ (6) -``` -**Solution:** Clean, organized, easy navigation - ---- - -## πŸŽ‰ Benefits - -### For Developers -- βœ… Easy to find relevant documentation -- βœ… Clear navigation paths -- βœ… Role-based organization -- βœ… Quick reference guides - -### For AI Assistants -- βœ… CLAUDE.md with key patterns -- βœ… Copilot instructions enhanced -- βœ… Agent system documented -- βœ… Architecture clearly defined - -### For Maintenance -- βœ… Single source of truth (docs/) -- βœ… Clear categories -- βœ… Easy to update -- βœ… Scalable structure - ---- - -## βœ… Verification Checklist - -- [x] All .md files categorized -- [x] Master INDEX.md created -- [x] docs/INDEX.md created -- [x] docs/README.md created -- [x] Cross-references working -- [x] Agent files in docs/agents/ -- [x] Original .claude/agents/ preserved -- [x] No broken links -- [x] Clear navigation paths -- [x] Beautiful formatting - ---- - -## πŸš€ Next Steps - -### Immediate -- βœ… Documentation organized -- βœ… Navigation guides created -- βœ… Cross-references verified - -### Optional Enhancements -- [ ] Add diagrams to architecture docs -- [ ] Create video tutorials -- [ ] Add code snippets to guides -- [ ] Generate PDF versions - ---- - -## πŸ“ Notes - -1. **Original files preserved**: `.claude/agents/` still contains original agent files -2. **Copies in docs**: `docs/agents/` contains copies for easy access -3. **No code changes**: Only documentation organization -4. **All links work**: Verified cross-references -5. **Git-ready**: Ready to commit - ---- - -**Status:** βœ… Complete and ready to use! -**Last Updated:** 2026-03-31 -**Version:** 1.0 diff --git a/docs/arcore-best-practices-cheatsheet.md b/docs/guides/arcore-best-practices.md similarity index 100% rename from docs/arcore-best-practices-cheatsheet.md rename to docs/guides/arcore-best-practices.md diff --git a/docs/ARCORE_QUICK_REFERENCE.md b/docs/guides/arcore-quick-reference.md similarity index 100% rename from docs/ARCORE_QUICK_REFERENCE.md rename to docs/guides/arcore-quick-reference.md diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/guides/deployment-guide.md similarity index 100% rename from docs/DEPLOYMENT_GUIDE.md rename to docs/guides/deployment-guide.md diff --git a/.github/workflows/QUICK_REFERENCE.md b/docs/guides/ios-ci-quick-reference.md similarity index 100% rename from .github/workflows/QUICK_REFERENCE.md rename to docs/guides/ios-ci-quick-reference.md diff --git a/docs/IOS_ISSUES_ANALYSIS.md b/docs/ios/ios-issues-analysis.md similarity index 100% rename from docs/IOS_ISSUES_ANALYSIS.md rename to docs/ios/ios-issues-analysis.md diff --git a/.github/workflows/IOS_WORKFLOW_README.md b/docs/ios/ios-workflow-readme.md similarity index 100% rename from .github/workflows/IOS_WORKFLOW_README.md rename to docs/ios/ios-workflow-readme.md diff --git a/docs/ANDROID_ARCORE_STATE_SYNC_FIX.md b/docs/reports/android-arcore-state-sync-fix.md similarity index 100% rename from docs/ANDROID_ARCORE_STATE_SYNC_FIX.md rename to docs/reports/android-arcore-state-sync-fix.md diff --git a/docs/android-arcore-summary.md b/docs/reports/android-arcore-summary.md similarity index 100% rename from docs/android-arcore-summary.md rename to docs/reports/android-arcore-summary.md diff --git a/docs/ANDROID_EXPERT_SESSION_SUMMARY.md b/docs/reports/android-expert-session-summary.md similarity index 100% rename from docs/ANDROID_EXPERT_SESSION_SUMMARY.md rename to docs/reports/android-expert-session-summary.md diff --git a/docs/ANDROID_FIX_COMPLETE.md b/docs/reports/android-fix-complete.md similarity index 100% rename from docs/ANDROID_FIX_COMPLETE.md rename to docs/reports/android-fix-complete.md diff --git a/docs/reports/documentation-cleanup-report.md b/docs/reports/documentation-cleanup-report.md deleted file mode 100644 index 7345ef8..0000000 --- a/docs/reports/documentation-cleanup-report.md +++ /dev/null @@ -1,322 +0,0 @@ -# Markdown Documentation Cleanup Report - -**Date**: 2026-04-01 -**Status**: βœ… Complete -**Duration**: ~30 minutes - ---- - -## πŸ“‹ Executive Summary - -Successfully reorganized and improved the markdown documentation structure across the ARSample project. Eliminated duplications, standardized naming conventions, and created a more maintainable documentation hierarchy. - -### Key Achievements -- βœ… Removed 7 duplicate agent files -- βœ… Consolidated 4 hit testing documents into organized structure -- βœ… Standardized 12 file names to kebab-case -- βœ… Updated 50+ broken links across 6 files -- βœ… Reduced total markdown files from 44 to 37 - ---- - -## πŸ”§ Changes Made - -### 1. Agent Duplication Removal βœ… - -**Problem**: Agent documentation existed in two locations -- `.claude/agents/` (7 files) ← **Kept as source** -- `docs/agents/` (7 files) ← **Deleted** - -**Actions**: -- Deleted entire `docs/agents/` directory -- Updated all references in `docs/INDEX.md` and `INDEX.md` to point to `.claude/agents/` - -**Files Deleted**: -``` -docs/agents/README.md -docs/agents/design-analysis-agent.md -docs/agents/android-expert-agent.md -docs/agents/ios-expert-agent.md -docs/agents/main-developer-agent.md -docs/agents/test-developer-agent.md -docs/agents/code-reviewer-agent.md -docs/agents/bug-fixer-agent.md -``` - -**Files Saved**: 7 files (0 bytes wasted on duplicates) - ---- - -### 2. Hit Testing Documentation Consolidation βœ… - -**Problem**: 4 separate hit testing files scattered in `docs/` root - -**Before**: -``` -docs/ -β”œβ”€β”€ HIT_TESTING_DESIGN.md -β”œβ”€β”€ HIT_TESTING_IMPLEMENTATION.md -β”œβ”€β”€ HIT_TESTING_QUICKREF.md -└── ANDROID_ARCORE_HIT_TEST_ANALYSIS.md -``` - -**After**: -``` -docs/guides/hit-testing/ -β”œβ”€β”€ README.md # Navigation hub (NEW) -β”œβ”€β”€ design.md # Renamed from HIT_TESTING_DESIGN.md -β”œβ”€β”€ implementation.md # Renamed from HIT_TESTING_IMPLEMENTATION.md -β”œβ”€β”€ quick-reference.md # Renamed from HIT_TESTING_QUICKREF.md -└── android-arcore-analysis.md # Renamed from ANDROID_ARCORE_HIT_TEST_ANALYSIS.md -``` - -**Benefits**: -- Centralized hit testing documentation -- Clear navigation with README.md -- Platform-specific docs grouped together -- Easier to maintain and extend - ---- - -### 3. File Naming Standardization βœ… - -**Problem**: Inconsistent naming (UPPERCASE vs lowercase vs kebab-case) - -**Convention Adopted**: `lowercase-with-hyphens.md` (kebab-case) - -**Files Renamed** (12 files): - -| Before (UPPERCASE_SNAKE_CASE) | After (kebab-case) | -|-------------------------------|-------------------| -| `DOCUMENTATION_ORGANIZATION_COMPLETE.md` | `documentation-organization-complete.md` | -| `architecture/TECHNICAL_ANALYSIS.md` | `architecture/technical-analysis.md` | -| `guides/CODE_REVIEW_CHECKLIST.md` | `guides/code-review-checklist.md` | -| `guides/TEST_IMPLEMENTATION_GUIDE.md` | `guides/test-implementation-guide.md` | -| `reports/TEST_COVERAGE_REPORT.md` | `reports/test-coverage-report.md` | -| `reports/TEST_FILES_SUMMARY.md` | `reports/test-files-summary.md` | -| `reports/CODE_FIXES_INDEX.md` | `reports/code-fixes-index.md` | -| `reports/CODE_FIXES_SUMMARY.md` | `reports/code-fixes-summary.md` | -| `reports/CHANGES_REFERENCE.md` | `reports/changes-reference.md` | -| `reports/IMPLEMENTATION_COMPLETE.md` | `reports/implementation-complete.md` | -| `ios/IOS_ARKIT_QUICK_FIX_GUIDE.md` | `ios/ios-arkit-quick-fix-guide.md` | -| `ios/IOS_ARKIT_HIT_TESTING_IMPLEMENTATION_REPORT.md` | `ios/ios-arkit-hit-testing-report.md` | - -**Benefits**: -- Consistent visual appearance -- Easier to type and remember -- Better URL compatibility -- Industry standard (GitHub, Jekyll, etc.) - ---- - -### 4. Link References Updated βœ… - -**Problem**: 50+ broken links after file moves and renames - -**Files Updated** (6 files): -1. `INDEX.md` - Root documentation hub -2. `docs/INDEX.md` - Master documentation index -3. `docs/README.md` - Docs folder README -4. `docs/guides/hit-testing/README.md` - Hit testing hub -5. `docs/android-arcore-summary.md` - Android summary -6. `docs/ios/README.md` - iOS documentation index - -**Link Categories Fixed**: -- Agent references: `./docs/agents/` β†’ `../.claude/agents/` -- Hit testing: `./HIT_TESTING_*.md` β†’ `./guides/hit-testing/*.md` -- Uppercase files: `TECHNICAL_ANALYSIS.md` β†’ `technical-analysis.md` -- iOS files: `IOS_ARKIT_*.md` β†’ `ios-arkit-*.md` - -**Verification**: All internal links now valid - ---- - -## πŸ“Š Before & After Comparison - -### File Count - -| Category | Before | After | Change | -|----------|--------|-------|--------| -| **Total Files** | 44 | 37 | -7 | -| Agent Files | 14 | 7 | -7 (removed duplicates) | -| Hit Testing | 4 scattered | 5 organized | +1 (added README) | -| Naming Consistency | ~60% | 100% | +40% | - -### Directory Structure - -**Before**: -``` -docs/ -β”œβ”€β”€ UPPERCASE_FILES.md (scattered) -β”œβ”€β”€ lowercase-files.md (scattered) -β”œβ”€β”€ agents/ (duplicate) -β”œβ”€β”€ HIT_TESTING_*.md (4 files at root) -└── ... -``` - -**After**: -``` -docs/ -β”œβ”€β”€ lowercase-files.md (consistent) -β”œβ”€β”€ guides/ -β”‚ β”œβ”€β”€ hit-testing/ (organized) -β”‚ β”‚ β”œβ”€β”€ README.md -β”‚ β”‚ β”œβ”€β”€ design.md -β”‚ β”‚ β”œβ”€β”€ implementation.md -β”‚ β”‚ β”œβ”€β”€ quick-reference.md -β”‚ β”‚ └── android-arcore-analysis.md -β”‚ β”œβ”€β”€ code-review-checklist.md -β”‚ └── test-implementation-guide.md -└── ... -``` - ---- - -## βœ… Quality Improvements - -### 1. Discoverability -- βœ… Hit testing docs now have a dedicated hub (README.md) -- βœ… Clear categorization (guides, architecture, reports, platform) -- βœ… Consistent naming makes files easier to find - -### 2. Maintainability -- βœ… Single source of truth for agent docs (`.claude/agents/`) -- βœ… No duplicate content to keep in sync -- βœ… Organized structure reduces cognitive load - -### 3. Navigation -- βœ… All broken links fixed -- βœ… Hub files (README.md) provide clear entry points -- βœ… Breadcrumb-style organization (guides/hit-testing/design.md) - -### 4. Consistency -- βœ… 100% kebab-case naming convention -- βœ… Standardized directory structure -- βœ… Predictable file locations - ---- - -## 🎯 Impact Assessment - -### Developer Experience -- **Search**: Easier to find files with consistent naming -- **Navigation**: Logical grouping reduces confusion -- **Maintenance**: Single source for agents eliminates sync issues -- **Onboarding**: Clear structure helps new developers - -### Documentation Health -- **Accuracy**: All links verified and working -- **Organization**: Topic-based grouping (hit-testing/) -- **Scalability**: Easy to add new docs in established structure -- **Standards**: Industry-standard kebab-case naming - ---- - -## πŸ“ Final Structure - -``` -ARSample/ -β”œβ”€β”€ CLAUDE.md (kept) -β”œβ”€β”€ INDEX.md (updated) -β”œβ”€β”€ README.md (kept) -β”œβ”€β”€ .claude/ -β”‚ └── agents/ (7 files - source of truth) -β”œβ”€β”€ .github/ -β”‚ └── copilot-instructions.md (kept) -└── docs/ - β”œβ”€β”€ INDEX.md (updated - master index) - β”œβ”€β”€ README.md (updated) - β”œβ”€β”€ android-arcore-summary.md - β”œβ”€β”€ arcore-best-practices-cheatsheet.md - β”œβ”€β”€ documentation-organization-complete.md βœ“ renamed - β”œβ”€β”€ architecture/ - β”‚ └── technical-analysis.md βœ“ renamed - β”œβ”€β”€ guides/ - β”‚ β”œβ”€β”€ code-review-checklist.md βœ“ renamed - β”‚ β”œβ”€β”€ test-implementation-guide.md βœ“ renamed - β”‚ └── hit-testing/ βœ“ NEW organized structure - β”‚ β”œβ”€β”€ README.md βœ“ NEW - β”‚ β”œβ”€β”€ design.md βœ“ moved & renamed - β”‚ β”œβ”€β”€ implementation.md βœ“ moved & renamed - β”‚ β”œβ”€β”€ quick-reference.md βœ“ moved & renamed - β”‚ └── android-arcore-analysis.md βœ“ moved & renamed - β”œβ”€β”€ ios/ - β”‚ β”œβ”€β”€ README.md (updated) - β”‚ β”œβ”€β”€ ios-arkit-hit-testing-report.md βœ“ renamed - β”‚ β”œβ”€β”€ ios-arkit-quick-fix-guide.md βœ“ renamed - β”‚ β”œβ”€β”€ ios-expert-report.md - β”‚ β”œβ”€β”€ ios-expert-summary.md - β”‚ β”œβ”€β”€ ios-implementation-checklist.md - β”‚ β”œβ”€β”€ ios-implementation-code-examples.md - β”‚ └── ios-quick-reference.md - └── reports/ - β”œβ”€β”€ changes-reference.md βœ“ renamed - β”œβ”€β”€ code-fixes-index.md βœ“ renamed - β”œβ”€β”€ code-fixes-summary.md βœ“ renamed - β”œβ”€β”€ implementation-complete.md βœ“ renamed - β”œβ”€β”€ test-coverage-report.md βœ“ renamed - └── test-files-summary.md βœ“ renamed -``` - ---- - -## πŸš€ Next Steps (Recommendations) - -### Optional Future Improvements -1. **Add `.markdownlint.json`** - Enforce markdown standards -2. **Link checker CI/CD** - Automated broken link detection -3. **Doc versioning** - Track major doc changes -4. **Navigation breadcrumbs** - Add "< Back to INDEX" links - -### Maintenance Guidelines -1. **New Files**: Always use `kebab-case-naming.md` -2. **New Categories**: Create subdirectories (e.g., `docs/guides/testing/`) -3. **Agent Docs**: Only update `.claude/agents/`, never duplicate -4. **Links**: Use relative paths (`./`, `../`) - ---- - -## πŸ“ˆ Metrics - -| Metric | Value | -|--------|-------| -| **Files Deleted** | 7 | -| **Files Renamed** | 12 | -| **Files Moved** | 4 | -| **Links Fixed** | 50+ | -| **New Files Created** | 1 (hit-testing/README.md) | -| **Time Saved** | ~2 hours (no manual cleanup needed) | -| **Disk Space Saved** | ~50KB (duplicate removal) | -| **Consistency Score** | 100% (was 60%) | - ---- - -## βœ… Verification Checklist - -- [x] No duplicate files exist -- [x] All files use kebab-case naming -- [x] Hit testing docs organized in `guides/hit-testing/` -- [x] Agent docs reference `.claude/agents/` -- [x] All internal links verified -- [x] Hub files (INDEX.md, README.md) updated -- [x] Navigation structure logical -- [x] Plan.md documented in session folder - ---- - -## πŸ† Summary - -**Mission Accomplished!** The markdown documentation is now: -- βœ… **Organized** - Clear hierarchy with topic-based grouping -- βœ… **Consistent** - 100% kebab-case naming convention -- βœ… **Accurate** - All links verified and working -- βœ… **Maintainable** - Single source of truth, no duplicates -- βœ… **Scalable** - Easy to extend with new documentation - -**Developer Impact**: Improved discoverability, faster navigation, and reduced maintenance overhead. - ---- - -**Report Generated**: 2026-04-01 15:35 UTC -**Executed By**: GitHub Copilot CLI -**Session ID**: b841847c-0f40-4880-b695-6c19f1b56d2e diff --git a/docs/FIX-DRAG-DELETE.md b/docs/reports/fix-drag-delete.md similarity index 100% rename from docs/FIX-DRAG-DELETE.md rename to docs/reports/fix-drag-delete.md diff --git a/.github/workflows/IMPLEMENTATION_SUMMARY.md b/docs/reports/ios-workflow-implementation-summary.md similarity index 100% rename from .github/workflows/IMPLEMENTATION_SUMMARY.md rename to docs/reports/ios-workflow-implementation-summary.md diff --git a/docs/reports/project-restructuring-report.md b/docs/reports/project-restructuring-report.md deleted file mode 100644 index 4fd1189..0000000 --- a/docs/reports/project-restructuring-report.md +++ /dev/null @@ -1,118 +0,0 @@ -# ARSample Project Restructuring Report - -**Date:** 2026-04-01 -**Task:** Align project structure with documentation (CLAUDE.md, COPILOT.md) - -## Summary - -BaşarΔ±yla tamamlandΔ±! Projeye **DDD + Clean Architecture** yapΔ±sΔ± iΓ§in gerekli temel bileşenler eklendi. - -## Changes Made - -### 1. βœ… Base Classes Created (`domain/base/`) - -TΓΌm domain katmanΔ± iΓ§in temel interface'ler oluşturuldu: - -| File | Purpose | Lines | -|------|---------|-------| -| `BaseModel.kt` | Marker interface for domain models | 6 | -| `BaseUseCase.kt` | Typed use case pattern (Input β†’ Output) | 11 | -| `BaseRepository.kt` | Marker interface for repositories | 6 | -| `BaseMapper.kt` | DTO ↔ Model transformation | 15 | - -**Pattern:** Halleder Flutter projelerinden alΔ±nan Clean Architecture pattern'i. - -### 2. βœ… Exception Hierarchy (`domain/exception/`) - -Domain-level exception hierarchy oluşturuldu: - -```kotlin -DomainException (sealed base) - β”œβ”€β”€ ValidationException - Input validation failures - β”œβ”€β”€ EntityNotFoundException - Entity not found - β”œβ”€β”€ StorageException - Data persistence errors - └── BusinessRuleException - Business logic violations -``` - -**File:** `DomainException.kt` (47 lines) - -### 3. βœ… Value Objects (`domain/model/valueobjects/`) - -Domain validation iΓ§in immutable value objects: - -| Value Object | Validates | Rules | -|--------------|-----------|-------| -| `ModelUri` | 3D model file paths | .glb, .usdz, .fbx, .obj extensions | -| `ObjectName` | Object names | 1-50 chars, non-blank | - -**Pattern:** -- Sealed class with private constructor -- `create()` factory returns `Result` -- Prevents invalid state creation - -Example: -```kotlin -val nameResult = ObjectName.create("Modern Chair") -nameResult.fold( - onSuccess = { name -> use(name.value) }, - onFailure = { error -> handleError(error) } -) -``` - -## Architecture Impact - -### Before -``` -domain/ -β”œβ”€β”€ model/ (basic entities) -β”œβ”€β”€ repository/ (interfaces) -└── usecase/ (implementations) -``` - -### After -``` -domain/ -β”œβ”€β”€ base/ ← NEW: Foundation layer -β”œβ”€β”€ exception/ ← NEW: Domain exceptions -β”œβ”€β”€ model/ -β”‚ └── valueobjects/ ← NEW: Validation layer -β”œβ”€β”€ repository/ -└── usecase/ -``` - -## Benefits - -1. **Type Safety:** Value Objects prevent invalid data at compile time -2. **Validation:** Business rules enforced in domain layer -3. **Consistency:** Base classes standardize patterns across codebase -4. **Error Handling:** Typed exceptions with Result pattern -5. **Testability:** Clear separation of concerns - -## Next Steps (Future) - -1. **DTO Layer:** Create `data/dto/` with serializable DTOs -2. **Refactor Existing:** Update current models to use Value Objects -3. **Use Case Inputs:** Add Input/Output models to use cases -4. **Mappers:** Implement BaseMapper for existing entities -5. **Tests:** Add unit tests for Value Objects and exceptions - -## Files Summary - -| Category | Files Added | Total Lines | -|----------|-------------|-------------| -| Base Classes | 4 | 38 | -| Exceptions | 1 | 47 | -| Value Objects | 2 | 80 | -| **Total** | **7** | **165** | - -## Compliance - -βœ… Follows CLAUDE.md patterns -βœ… Implements Halleder-inspired DDD -βœ… Uses sealed class + Result pattern -βœ… Zero breaking changes to existing code -βœ… Documentation-first approach - ---- - -**Conclusion:** Proje artΔ±k dokΓΌmantasyonda tanΔ±mlanan Clean Architecture yapΔ±sΔ±na sahip. Mevcut kodlar bozulmadΔ±, yeni pattern'ler eklenmeye hazΔ±r. diff --git a/TEST_SUMMARY.md b/docs/reports/test-summary.md similarity index 100% rename from TEST_SUMMARY.md rename to docs/reports/test-summary.md 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