diff --git a/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt b/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt index 8b84b34..03e5c8d 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/BowlingCameraActivity.kt @@ -61,6 +61,18 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { // doc. Open only while a recording is in progress. private lateinit var debugSessionLogger: DebugSessionLogger + // class for FeedbackUI + private lateinit var feedbackUI: FeedbackUI + private val stepLabels = listOf( + R.string.starting_position, + R.string.first_step, + R.string.second_step, + R.string.third_step, + R.string.fourth_step, + R.string.end_position + ) + private var currentStepIndex = 0 + private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> val granted = CameraPermissions.allGranted(this) @@ -87,7 +99,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { cameraExecutor = Executors.newSingleThreadExecutor() cameraXController = CameraXController(applicationContext, cameraExecutor) debugSessionLogger = DebugSessionLogger(applicationContext) + feedbackUI = FeedbackUI(this, binding.root) + binding.poseOverlay.attachFeedback(feedbackUI) binding.btnGrantPermissions.setOnClickListener { permissionLauncher.launch(CameraPermissions.REQUIRED) } @@ -95,6 +109,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { binding.switchPose.setOnCheckedChangeListener { _, isChecked -> viewModel.onPoseToggled(isChecked) } binding.btnSwitchCamera.setOnClickListener { onSwitchCameraClicked() } binding.btnBack.setOnClickListener { finish() } + binding.btnShowStep.setOnClickListener { onStepIncrease() } observeViewModel() @@ -114,7 +129,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { lifecycleOwner = this, previewView = binding.cameraPreview, callback = this, - lensFacing = lensFacing + lensFacing = lensFacing, + feedbackUi = feedbackUI ) } @@ -216,12 +232,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { // Pose mode can only be changed between recordings, not // mid-flight -- see setPoseDetectionEnabled()'s doc comment. binding.switchPose.isEnabled = true + // Feedback UI - buttons only shown when recording + binding.btnShowStep.isEnabled = false } is CameraViewModel.RecordingState.Starting -> { // Can't stop a recording that hasn't started yet, and pose // mode for it is already locked in. binding.btnRecord.isEnabled = false binding.switchPose.isEnabled = false + binding.btnShowStep.isEnabled = true + binding.btnShowStep.setText(R.string.starting_position) } is CameraViewModel.RecordingState.Recording -> { binding.btnRecord.isEnabled = true @@ -231,6 +251,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { val minutes = state.elapsedSeconds / 60 val seconds = state.elapsedSeconds % 60 binding.textTimer.text = String.format(Locale.US, "%02d:%02d", minutes, seconds) + binding.btnShowStep.isEnabled = true } } } @@ -347,4 +368,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback { cameraExecutor.shutdown() debugSessionLogger.stop() } + + fun onStepIncrease() { + currentStepIndex = (currentStepIndex + 1) % stepLabels.size + binding.btnShowStep.setText(stepLabels[currentStepIndex]) + } } diff --git a/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt b/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt index e0139b9..3ae11a9 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/CameraXController.kt @@ -116,6 +116,8 @@ class CameraXController( } } + private var feedbackUI: FeedbackUI? = null + /** @brief Whether a video recording is currently in progress. */ val isRecording: Boolean get() = activeRecording != null @@ -140,10 +142,12 @@ class CameraXController( lifecycleOwner: LifecycleOwner, previewView: PreviewView, callback: Callback, - lensFacing: Int = CameraSelector.LENS_FACING_BACK + lensFacing: Int = CameraSelector.LENS_FACING_BACK, + feedbackUi: FeedbackUI ) { this.callback = callback this.currentLensFacing = lensFacing + this.feedbackUI = feedbackUi val providerFuture = ProcessCameraProvider.getInstance(appContext) providerFuture.addListener({ @@ -267,6 +271,8 @@ class CameraXController( mirror = frame.isMirroring ) PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint) + feedbackUI?.drawCircles(canvas, poseFrame.landmarks, transform, true) + feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step", true) } true } diff --git a/app/src/main/java/com/example/jnicpp/bowling/FeedbackUI.kt b/app/src/main/java/com/example/jnicpp/bowling/FeedbackUI.kt new file mode 100644 index 0000000..ce83d54 --- /dev/null +++ b/app/src/main/java/com/example/jnicpp/bowling/FeedbackUI.kt @@ -0,0 +1,140 @@ +package com.example.jnicpp.bowling + +import android.text.Layout +import android.text.TextPaint +import android.text.StaticLayout +import android.graphics.Canvas +import android.graphics.RectF +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.content.Context +import android.graphics.Paint +import android.view.View +import android.widget.TextView +import com.example.jnicpp.R +import android.graphics.BlurMaskFilter +import android.graphics.Matrix +import kotlin.math.min +import androidx.constraintlayout.widget.ConstraintLayout + + +class FeedbackUI(private val context: Context, private val rootView: View) { + private var landmarks: Map? = null + private var CIRCLE_RADIUS = 128f + private val uiTextSize = 32f + private val uiStrokeWidth = 12f + private val bannerPaddingY = 24 + private val bannerPaddingX = 24 + private val camScale = 0.5f + + private var liveUiWidth: Int = 0 + private var liveUiHeight: Int = 0 + + private val glowPaint = Paint().apply { + color = Color.RED + style = Paint.Style.STROKE + isAntiAlias = true + strokeWidth = uiStrokeWidth // thicker stroke so glow is visible + maskFilter = BlurMaskFilter(25f, BlurMaskFilter.Blur.OUTER) + } + + private val circlePaint = Paint().apply { + color = Color.WHITE + style = Paint.Style.STROKE + isAntiAlias = true + strokeWidth = uiStrokeWidth + } + + private val textPaint = TextPaint().apply { + color = Color.WHITE + textSize = uiTextSize + isAntiAlias = true + } + + private val bgPaint = Paint().apply { + color = Color.argb(64, 255, 255, 255) + isAntiAlias = true + } + + init { + rootView.viewTreeObserver.addOnGlobalLayoutListener { + setLiveUiSize(rootView) + } + } + + fun showBanner(canvas: Canvas, message: String, forRecord: Boolean = false) { + val scale = if (forRecord) computeCamScale(canvas) else 1f + val paddingX = bannerPaddingX * scale + val paddingY = bannerPaddingY * scale + val maxWidth = (canvas.width * 0.8f).toInt() + + // Build StaticLayout for multi-line text + textPaint.textSize = uiTextSize * scale + val staticLayout = StaticLayout.Builder + .obtain(message, 0, message.length, textPaint, maxWidth) + .setAlignment(Layout.Alignment.ALIGN_CENTER) // center text horizontally + .setLineSpacing(0f, 1f) + .setIncludePad(false) + .build() + + // Use StaticLayout dimensions + val textWidth = staticLayout.width.toFloat() + val textHeight = staticLayout.height.toFloat() + + val bannerWidth = textWidth + paddingX * 2 + val bannerHeight = textHeight + paddingY * 2 + + // Center horizontally + val left = (canvas.width - bannerWidth) / 2f + val top = 172f * scale + val right = left + bannerWidth + val bottom = top + bannerHeight + + // Draw background + val rect = RectF(left, top, right, bottom) + + // Scale corner radius too + val cornerRadius = 24f * scale + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint) + + // Draw text layout inside background + canvas.save() + canvas.translate(left + paddingX, top + paddingY) + staticLayout.draw(canvas) + canvas.restore() + } + + // --- Shape overlay (circle) --- + fun drawCircles(canvas: Canvas, landmarks: Map, transform: Matrix, forRecord: Boolean = false) { + for (landmark in landmarks.values) { + val point = floatArrayOf(landmark.x, landmark.y) + transform.mapPoints(point) + drawCircle(canvas, point[0], point[1], 0f, forRecord) + } + } + + fun drawCircle(canvas: Canvas, x: Float, y: Float, radius: Float = 0f, forRecord: Boolean = false) { + val scale = if (forRecord) camScale else 1f + + var rad = (if (radius == 0f) CIRCLE_RADIUS else radius) * scale + circlePaint.strokeWidth = uiStrokeWidth * scale + glowPaint.strokeWidth = uiStrokeWidth * scale + canvas.drawCircle(x, y, rad, circlePaint) + canvas.drawCircle(x, y, rad, glowPaint) + } + + // Store live UI size once + fun setLiveUiSize(rootView: View) { + liveUiWidth = rootView.width + liveUiHeight = rootView.height + } + + // Compute scale when drawing to recording canvas + private fun computeCamScale(recordCanvas: Canvas): Float { + if (liveUiWidth == 0 || liveUiHeight == 0) return 1f + val scaleX = recordCanvas.width.toFloat() / liveUiWidth.toFloat() + val scaleY = recordCanvas.height.toFloat() / liveUiHeight.toFloat() + return min(scaleX, scaleY) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt b/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt index ca2e5b4..a34e597 100644 --- a/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt +++ b/app/src/main/java/com/example/jnicpp/bowling/PoseOverlayView.kt @@ -12,6 +12,7 @@ import android.util.AttributeSet import android.view.View import androidx.core.content.ContextCompat import com.example.jnicpp.R +import com.google.mlkit.vision.pose.PoseLandmark // for testing /** * @brief Draws the 33 ML Kit pose landmarks and connecting skeleton lines @@ -63,6 +64,8 @@ class PoseOverlayView @JvmOverloads constructor( private var transform = Matrix() + private var feedbackUI: FeedbackUI? = null + /** * @brief Updates the view with the latest analyzer result and triggers a redraw. * @param frame The latest analyzer result to draw, or null to clear the overlay. @@ -123,5 +126,25 @@ class PoseOverlayView @JvmOverloads constructor( val currentLandmarks = landmarks ?: return PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint) angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) } + + // currentLandmarks to change to landmarks that require highlighting + // test code to contain only left wrist in currentLandmarks to not clutter the screen + val leftWrist = currentLandmarks[PoseLandmark.LEFT_WRIST] + + // Build a single‑item map if it exists + val singleLandmark = if (leftWrist != null) { + mapOf(PoseLandmark.LEFT_WRIST to leftWrist) + } else { + emptyMap() + } + // end test code + feedbackUI?.drawCircles(canvas, singleLandmark, transform) + + // Trigger feedback advice for this step + feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step efsdfdg d dg df gdgdfg df ") + } + + fun attachFeedback(feedbackUI: FeedbackUI) { + this.feedbackUI = feedbackUI } } diff --git a/app/src/main/res/layout-land/activity_bowling_camera.xml b/app/src/main/res/layout-land/activity_bowling_camera.xml index 6bed5e3..f941f0e 100644 --- a/app/src/main/res/layout-land/activity_bowling_camera.xml +++ b/app/src/main/res/layout-land/activity_bowling_camera.xml @@ -125,6 +125,17 @@ app:layout_constraintBottom_toTopOf="@id/btn_record" app:layout_constraintEnd_toEndOf="@id/btn_record" /> + +