Cleaned up merge UI, added reset step button

This commit is contained in:
Gabriel Low
2026-09-10 12:09:46 +08:00
parent ac4366d04d
commit 17fad430c1
9 changed files with 170 additions and 91 deletions
@@ -133,6 +133,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
startActivity(Intent(this, ParameterEditorActivity::class.java))
}
}
binding.btnResetCounter.setOnClickListener { viewModel.resetStepCounter() }
observeViewModel()
@@ -243,7 +244,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// detection is actually seeing each footfall while
// testing/tuning it, rather than only finding out at
// step 5 that earlier steps were silently missed.
if (events.isEmpty()) {
val isRecording = viewModel.recordingState.value is CameraViewModel.RecordingState.Recording
if (events.isEmpty() || !isRecording) {
binding.textFinalPosition.visibility = View.GONE
} else {
val reachedFinal = events.size >= FINAL_STEP_COUNT
@@ -264,8 +266,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
}
launch {
viewModel.poseStageFeedback.collect { feedback ->
val isRecording = viewModel.recordingState.value is CameraViewModel.RecordingState.Recording
binding.textPoseStageFeedback.text = feedback
binding.textPoseStageFeedback.visibility = if (feedback != null) View.VISIBLE else View.GONE
binding.textPoseStageFeedback.visibility = if ((feedback != null && isRecording)) View.VISIBLE else View.GONE
}
}
// Deliberately its own collector, independent of stepEvents
@@ -314,6 +317,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
is CameraViewModel.RecordingState.Idle -> {
binding.layoutRecordingIndicator.visibility = View.GONE
stepCounterUi.setVisible(visible = false)
binding.textFinalPosition.visibility = View.GONE
binding.textPoseStageFeedback.visibility = View.GONE
binding.btnRecord.isEnabled = true
binding.btnRecord.setText(R.string.record)
// Pose mode can only be changed between recordings, not
@@ -149,17 +149,14 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
fun onPoseFrameUpdated(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles) {
_poseAngles.value = angles
// Update pose phase detector with this frame's landmarks and angles
// Update pose phase detector with this frame's landmarks and angles (independent of step counter)
val phaseResult = posePhaseDetector.update(landmarks, angles)
val stepCount = stepEvents.value.size
val stepPhase = PosePhaseDetector.phaseForStep(stepCount)
_posePhase.value = phaseResult.phase ?: stepPhase
_posePhase.value = phaseResult.phase
_poseMetrics.value = phaseResult.metrics
// Check if the current pose matches the Starting Stance
// Check if the current pose matches the Starting Stance (pure posture query)
val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE)
|| posePhaseDetector.isStartingStanceValid(phaseResult.metrics)
|| (PoseStageAdvisor.feedback(stepCount.takeIf { it > 0 }, angles)?.contains("Starting position") == true)
if (_recordingState.value is RecordingState.Recording) {
stepCountingSession.onFrame(
@@ -168,13 +165,20 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
timestampMs = System.currentTimeMillis(),
isStartingPosition = isStartingStance,
)
val currentStepCount = stepEvents.value.size
_poseStageFeedback.value = PoseStageAdvisor.feedback(
stepNumber = stepCount.takeIf { it > 0 },
stepNumber = currentStepCount.takeIf { it > 0 },
angles = angles,
)
}
}
/** @brief Manually resets step counting state for the current recording session. */
fun resetStepCounter() {
stepCountingSession.resetStepCounter()
_poseStageFeedback.value = null
}
/**
* @brief Marks a recording as being requested and resets all
* per-session buffering/detection state.
@@ -152,6 +152,20 @@ class CameraXController(
style = Paint.Style.FILL
}
}
private val overlayCardBorderPaint by lazy {
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(appContext, R.color.step_counter_accent)
style = Paint.Style.STROKE
strokeWidth = 3f
}
}
private val overlayAccentTextPaint by lazy {
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(appContext, R.color.step_counter_accent)
textSize = 14f
isFakeBoldText = true
}
}
private var feedbackUI: FeedbackUI? = null
@@ -316,7 +330,7 @@ class CameraXController(
feedbackUI?.drawCircles(canvas, singleLandmark, transform, forRecord = true)
val state = recordingOverlayStateProvider?.invoke()
val scale = (minOf(targetWidth, targetHeight) / 720f).coerceAtLeast(1f)
val scale = feedbackUI?.computeCamScale(canvas) ?: (minOf(targetWidth, targetHeight) / 1080f)
// 1. Step Counter Card (Top Center)
val stepCount = state?.stepCount ?: 0
@@ -327,7 +341,7 @@ class CameraXController(
drawPhaseBadgeOverlay(canvas, phase, scale)
}
// 3. Body Angle Metrics Readout (Top Left)
// 3. Body Angle Metrics Readout (Bottom Left)
state?.metrics?.let { metrics ->
drawMetricsOverlay(canvas, metrics, scale)
}
@@ -345,22 +359,49 @@ class CameraXController(
}
private fun drawStepCounterOverlay(canvas: Canvas, stepCount: Int, scale: Float) {
val countText = "$stepCount STEPS"
val textPaint = overlayTextPaint.apply { textSize = 26f * scale }
val textWidth = textPaint.measureText(countText)
val paddingX = 18f * scale
val paddingY = 8f * scale
val cardWidth = textWidth + (paddingX * 2f)
val cardHeight = (26f * scale) + (paddingY * 2f)
val numberText = stepCount.toString()
val labelText = "STEPS"
val numPaint = overlayTextPaint.apply { textSize = 32f * scale }
val labelPaint = overlayAccentTextPaint.apply {
textSize = 11f * scale
strokeWidth = 0f
style = Paint.Style.FILL
}
val numWidth = numPaint.measureText(numberText)
val labelWidth = labelPaint.measureText(labelText)
val contentWidth = maxOf(numWidth, labelWidth)
val paddingX = 20f * scale
val paddingY = 6f * scale
val cardWidth = contentWidth + (paddingX * 2f)
val cardHeight = (32f * scale) + (11f * scale) + (paddingY * 2f)
val left = (canvas.width - cardWidth) / 2f
val top = 32f * scale
val top = 104f * scale
val right = left + cardWidth
val bottom = top + cardHeight
val rect = RectF(left, top, right, bottom)
canvas.drawRoundRect(rect, 14f * scale, 14f * scale, overlayScrimPaint)
canvas.drawText(countText, left + paddingX, top + paddingY + (22f * scale), textPaint)
val radius = 16f * scale
// Draw card background
canvas.drawRoundRect(rect, radius, radius, overlayScrimPaint)
// Draw card border
overlayCardBorderPaint.strokeWidth = 2f * scale
canvas.drawRoundRect(rect, radius, radius, overlayCardBorderPaint)
// Draw big number text (centered)
val numX = left + ((cardWidth - numWidth) / 2f)
val numY = top + paddingY + (28f * scale)
canvas.drawText(numberText, numX, numY, numPaint)
// Draw "STEPS" label text (centered below number)
val labelX = left + ((cardWidth - labelWidth) / 2f)
val labelY = numY + (14f * scale)
canvas.drawText(labelText, labelX, labelY, labelPaint)
}
private fun drawPhaseBadgeOverlay(canvas: Canvas, phase: BowlingPhase, scale: Float) {
@@ -380,16 +421,16 @@ class CameraXController(
BowlingPhase.POWER_STEP -> R.color.Power_step_ready
BowlingPhase.SLIDE_AND_RELEASE -> R.color.Slide_and_release_ready
}
val textPaint = overlayTextPaint.apply { textSize = 20f * scale }
val textPaint = overlayTextPaint.apply { textSize = 14f * scale }
val textWidth = textPaint.measureText(label)
val paddingX = 14f * scale
val paddingY = 6f * scale
val paddingX = 10f * scale
val paddingY = 4f * scale
val badgeWidth = textWidth + (paddingX * 2f)
val badgeHeight = (20f * scale) + (paddingY * 2f)
val badgeHeight = (14f * scale) + (paddingY * 2f)
val right = canvas.width - (24f * scale)
val right = canvas.width - (16f * scale)
val left = right - badgeWidth
val top = 32f * scale
val top = 112f * scale
val bottom = top + badgeHeight
val badgePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@@ -398,8 +439,8 @@ class CameraXController(
}
val rect = RectF(left, top, right, bottom)
canvas.drawRoundRect(rect, 12f * scale, 12f * scale, badgePaint)
canvas.drawText(label, left + paddingX, top + paddingY + (18f * scale), textPaint)
canvas.drawRoundRect(rect, 10f * scale, 10f * scale, badgePaint)
canvas.drawText(label, left + paddingX, top + paddingY + (12f * scale), textPaint)
}
private fun drawMetricsOverlay(canvas: Canvas, metrics: PosePhaseDetector.Metrics, scale: Float) {
@@ -407,24 +448,24 @@ class CameraXController(
val line1 = "Torso: ${angleText(metrics.torsoTiltDegrees)} · Knee L: ${angleText(metrics.leftKneeAngleDegrees)} R: ${angleText(metrics.rightKneeAngleDegrees)}"
val line2 = "Elbow L: ${angleText(metrics.leftElbowAngleDegrees)} R: ${angleText(metrics.rightElbowAngleDegrees)}"
val textPaint = overlayTextPaint.apply { textSize = 16f * scale }
val textPaint = overlayTextPaint.apply { textSize = 12f * scale }
val w1 = textPaint.measureText(line1)
val w2 = textPaint.measureText(line2)
val maxWidth = maxOf(w1, w2)
val paddingX = 12f * scale
val paddingY = 6f * scale
val paddingX = 8f * scale
val paddingY = 4f * scale
val cardWidth = maxWidth + (paddingX * 2f)
val cardHeight = (18f * 2f * scale) + (paddingY * 2f) + (4f * scale)
val cardHeight = (12f * 2f * scale) + (paddingY * 2f) + (4f * scale)
val left = 24f * scale
val top = 32f * scale
val left = 16f * scale
val bottom = canvas.height - (80f * scale)
val top = bottom - cardHeight
val right = left + cardWidth
val bottom = top + cardHeight
val rect = RectF(left, top, right, bottom)
canvas.drawRoundRect(rect, 10f * scale, 10f * scale, overlayScrimPaint)
canvas.drawText(line1, left + paddingX, top + paddingY + (15f * scale), textPaint)
canvas.drawText(line2, left + paddingX, top + paddingY + (15f * 2f * scale) + (4f * scale), textPaint)
canvas.drawRoundRect(rect, 8f * scale, 8f * scale, overlayScrimPaint)
canvas.drawText(line1, left + paddingX, top + paddingY + (11f * scale), textPaint)
canvas.drawText(line2, left + paddingX, top + paddingY + (11f * 2f * scale) + (4f * scale), textPaint)
}
/**
@@ -180,7 +180,7 @@ class FeedbackUI(rootView: View) {
* @param recordCanvas The canvas used for recording output.
* @return A float scale factor to apply when drawing to the recording canvas.
*/
private fun computeCamScale(recordCanvas: Canvas): Float {
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()
@@ -139,9 +139,6 @@ class PoseOverlayView @JvmOverloads constructor(
}
// 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")
}
/**
@@ -82,6 +82,14 @@ class StepCountingSession {
}
}
/**
* @brief Manually resets live step detector state and clears detected step events.
*/
fun resetStepCounter() {
liveStepDetector.reset()
_stepEvents.value = emptyList()
}
/**
* @brief Clears all buffering/detection state and rebuilds the step
* detector from [settings]; call when a new recording starts.
@@ -75,31 +75,12 @@
android:fontFamily="monospace" />
</LinearLayout>
<!-- Top Left: Body Angles Readout (below Recording Indicator / Back Button) -->
<TextView
android:id="@+id/text_pose_metrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:textColor="@color/white"
android:textSize="12sp"
android:fontFamily="monospace"
android:visibility="gone"
tools:visibility="visible"
tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°"
app:layout_constraintTop_toBottomOf="@id/layout_recording_indicator"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp" />
<!-- TOP MIDDLE: Step Counter Card -->
<LinearLayout
android:id="@+id/card_step_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginTop="24dp"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/shape_step_counter_card"
@@ -136,10 +117,10 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
app:layout_constraintTop_toBottomOf="@id/card_step_counter"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp">
android:layout_marginTop="96dp">
<TextView
android:id="@+id/text_final_position"
@@ -170,6 +151,25 @@
tools:visibility="visible" />
</LinearLayout>
<!-- Bottom Left: Body Angles Readout (above Pose Switch) -->
<TextView
android:id="@+id/text_pose_metrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:textColor="@color/white"
android:textSize="12sp"
android:fontFamily="monospace"
android:visibility="gone"
tools:visibility="visible"
tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°"
app:layout_constraintBottom_toTopOf="@id/switch_pose"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="12dp"
android:layout_marginStart="24dp" />
<!-- Delivery Phase Badge -->
<TextView
android:id="@+id/text_pose_feedback"
@@ -229,6 +229,17 @@
app:layout_constraintBottom_toTopOf="@id/btn_switch_camera"
app:layout_constraintEnd_toEndOf="@id/btn_record" />
<!-- Bottom Right: Manual Reset Step Counter Button -->
<Button
android:id="@+id/btn_reset_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
android:layout_marginBottom="16dp"
android:text="@string/reset_counter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Permission Rationale Screen -->
<LinearLayout
android:id="@+id/layout_permission_rationale"
@@ -75,25 +75,6 @@
android:fontFamily="monospace" />
</LinearLayout>
<!-- Top Left: Body Angles Readout (below Recording Indicator / Back Button) -->
<TextView
android:id="@+id/text_pose_metrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:textColor="@color/white"
android:textSize="12sp"
android:fontFamily="monospace"
android:visibility="gone"
tools:visibility="visible"
tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°"
app:layout_constraintTop_toBottomOf="@id/layout_recording_indicator"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp" />
<!-- Top Right Controls: Switch Camera & Settings Editor -->
<Button
android:id="@+id/btn_switch_camera"
@@ -128,18 +109,18 @@
android:textStyle="bold"
android:visibility="gone"
tools:visibility="visible"
tools:text="Get into starting stance"
tools:text="Starting Stance"
app:layout_constraintTop_toBottomOf="@id/btn_editor"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" />
<!-- TOP MIDDLE: Step Counter Card (Primary Retained Element) -->
<!-- TOP MIDDLE: Step Counter Card (Positioned below top controls) -->
<LinearLayout
android:id="@+id/card_step_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="56dp"
android:layout_marginTop="104dp"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/shape_step_counter_card"
@@ -170,16 +151,17 @@
android:textStyle="bold" />
</LinearLayout>
<!-- CENTERED BELOW STEP COUNTER: Final Position & Live Coaching Cue -->
<!-- CENTERED BELOW STEP COUNTER: Final Position & Live Coaching Cue
(Anchored to parent so it doesn't jump to the top of the screen when step counter becomes GONE) -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
app:layout_constraintTop_toBottomOf="@id/card_step_counter"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp">
android:layout_marginTop="176dp">
<TextView
android:id="@+id/text_final_position"
@@ -210,6 +192,25 @@
tools:visibility="visible" />
</LinearLayout>
<!-- Bottom Left: Body Angles Readout (above Pose Switch) -->
<TextView
android:id="@+id/text_pose_metrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/overlay_scrim"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:textColor="@color/white"
android:textSize="12sp"
android:fontFamily="monospace"
android:visibility="gone"
tools:visibility="visible"
tools:text="Torso: 12° · Knee L: 168° R: 170°&#10;Elbow L: 85° R: 90°"
app:layout_constraintBottom_toTopOf="@id/switch_pose"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="12dp"
android:layout_marginStart="16dp" />
<!-- BOTTOM CONTROLS: Pose Overlay Switch & Record Button -->
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_pose"
@@ -233,6 +234,17 @@
android:text="@string/record"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/switch_pose"
app:layout_constraintEnd_toStartOf="@id/btn_reset_counter" />
<!-- Bottom Right: Manual Reset Step Counter Button -->
<Button
android:id="@+id/btn_reset_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="24dp"
android:text="@string/reset_counter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Permission Rationale Screen -->
+1
View File
@@ -14,6 +14,7 @@
<string name="recording_timer_placeholder">00:00</string>
<string name="step_count_big_placeholder">0</string>
<string name="step_counter_label">STEPS</string>
<string name="reset_counter">Reset Steps</string>
<string name="reset_hint_idle">✋ Raise a hand, hold 5s to reset</string>
<string name="reset_hint_holding">Keep holding… %1$d%%</string>
<string name="step_count_placeholder">Step 0</string>