Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion app/src/main/java/net/duhowpi/ftmsbridge/LineChartView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ class LineChartView @JvmOverloads constructor(
strokeWidth = 2f
style = Paint.Style.STROKE
}
private val kmMarkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(160, 76, 175, 80)
strokeWidth = 3f
style = Paint.Style.STROKE
}

private val paddingLeft = 72f
private val paddingRight = 72f
Expand All @@ -72,13 +77,15 @@ class LineChartView @JvmOverloads constructor(
private var totalDurationSec: Int = 0
private var legendEntries: List<Pair<String, Int>> = emptyList()
private var tooltipIndex: Int? = null
private var kmMarkIndices: List<Int> = emptyList()

fun setData(vararg dataSeries: DataSeries, durationSec: Int) {
fun setData(vararg dataSeries: DataSeries, durationSec: Int, kmMarkIndices: List<Int> = emptyList()) {
series.clear()
series.addAll(dataSeries)
totalDurationSec = durationSec
legendEntries = dataSeries.map { Pair(it.label, it.color) }
tooltipIndex = null
this.kmMarkIndices = kmMarkIndices
invalidate()
}

Expand Down Expand Up @@ -147,6 +154,21 @@ class LineChartView @JvmOverloads constructor(
canvas.drawLine(chartLeft, chartTop, chartLeft, chartBottom, axisPaint)
canvas.drawLine(chartLeft, chartBottom, chartRight, chartBottom, axisPaint)

// --- Kilometre markers (vertical line + label at every completed km) ---
if (kmMarkIndices.isNotEmpty()) {
val maxPts = series.maxOf { it.points.size }
val lastIdx = (maxPts - 1).coerceAtLeast(1)
kmMarkIndices.forEachIndexed { i, pointIdx ->
if (pointIdx in 0..lastIdx) {
val x = chartLeft + (pointIdx.toFloat() / lastIdx) * chartW
canvas.drawLine(x, chartTop, x, chartBottom, kmMarkPaint)
labelPaint.color = kmMarkPaint.color
labelPaint.textAlign = Paint.Align.CENTER
canvas.drawText("${i + 1} km", x, chartTop + labelPaint.textSize, labelPaint)
}
}
}

// --- Series lines ---
series.forEachIndexed { idx, s ->
if (s.points.isEmpty()) return@forEachIndexed
Expand Down
53 changes: 46 additions & 7 deletions app/src/main/java/net/duhowpi/ftmsbridge/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.GestureDetector
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.widget.Button
import android.widget.EditText
Expand All @@ -31,6 +33,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.duhowpi.ftmsbridge.ble.BleConnectionManager
Expand All @@ -55,6 +58,7 @@ import net.duhowpi.ftmsbridge.model.ScannedDeviceInfo
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import kotlin.math.abs
import kotlin.math.roundToInt

class MainActivity : AppCompatActivity() {
Expand Down Expand Up @@ -108,6 +112,7 @@ class MainActivity : AppCompatActivity() {
private val liveSpeedPoints = mutableListOf<Float>()
private val livePaceSecondaryPoints = mutableListOf<Float>()
private val liveHrPoints = mutableListOf<Float>()
private val liveKmMarkIndices = mutableListOf<Int>()
private var liveChartElapsedSec = 0

// Lap tracking
Expand Down Expand Up @@ -335,9 +340,13 @@ class MainActivity : AppCompatActivity() {
if (isActiveTreadmill) showInclineControlDialog() else showResistanceControlDialog()
}

binding.viewToggleRow.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) setWorkoutView(checkedId == R.id.btnViewChart)
}
binding.viewToggleRow.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
setWorkoutView(tab.position == 1)
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})

updateConnectionStatus()
// Apply initial Idle UI (which also resets all metric displays).
Expand All @@ -348,12 +357,34 @@ class MainActivity : AppCompatActivity() {

private fun setWorkoutView(showChart: Boolean) {
isChartViewActive = showChart
binding.viewToggleRow.check(if (showChart) R.id.btnViewChart else R.id.btnViewLap)
binding.lapSection.visibility = if (!showChart) View.VISIBLE else View.GONE
binding.chartSection.visibility = if (showChart) View.VISIBLE else View.GONE
val tab = binding.viewToggleRow.getTabAt(if (showChart) 1 else 0)
if (tab != null && !tab.isSelected) tab.select()
// INVISIBLE (not GONE) keeps the container sized to the tallest view,
// so the card area never moves when switching views.
binding.lapSection.visibility = if (!showChart) View.VISIBLE else View.INVISIBLE
binding.chartSection.visibility = if (showChart) View.VISIBLE else View.INVISIBLE
if (showChart) updateLiveChart()
}

/** Detects horizontal swipes anywhere on screen to switch lap/chart views. */
private val workoutSwipeDetector by lazy {
GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
if (e1 == null || binding.viewToggleRow.visibility != View.VISIBLE) return false
val dx = e2.x - e1.x
if (abs(dx) < SWIPE_MIN_DISTANCE_PX || abs(velocityX) < SWIPE_MIN_VELOCITY ||
abs(dx) < abs(e2.y - e1.y)) return false
setWorkoutView(dx < 0)
return true
}
})
}

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
workoutSwipeDetector.onTouchEvent(ev)
return super.dispatchTouchEvent(ev)
}

private fun updateLiveChart() {
if (liveSpeedPoints.isEmpty()) return
val isTreadmill = isActiveTreadmill
Expand Down Expand Up @@ -381,7 +412,7 @@ class MainActivity : AppCompatActivity() {
if (hasNonZeroValues(liveHrPoints)) {
seriesList.add(LineChartView.DataSeries(getString(R.string.metric_heart_rate), hrColor, liveHrPoints.toList(), 40f, 200f))
}
binding.liveChart.setData(*seriesList.toTypedArray(), durationSec = durationSec)
binding.liveChart.setData(*seriesList.toTypedArray(), durationSec = durationSec, kmMarkIndices = liveKmMarkIndices.toList())
}

private fun updateLapView(sample: FitnessSample) {
Expand Down Expand Up @@ -1103,6 +1134,8 @@ class MainActivity : AppCompatActivity() {
if (isTreadmill) (fitnessDevice?.getIncline(sample) ?: sample.inclinationPercent).toFloat() else resolveDisplayResistanceLevel(sample).toFloat()
)
liveHrPoints.add(sample.heartRateBpm.toFloat())
val kmCount = sample.totalDistanceM / 1000
while (liveKmMarkIndices.size < kmCount) liveKmMarkIndices.add(liveSpeedPoints.size - 1)
if (isChartViewActive) updateLiveChart()
updateLapView(sample)
}
Expand Down Expand Up @@ -1232,6 +1265,7 @@ class MainActivity : AppCompatActivity() {
liveSpeedPoints.clear()
livePaceSecondaryPoints.clear()
liveHrPoints.clear()
liveKmMarkIndices.clear()
liveChartElapsedSec = 0
lapStartDistanceM = 0
lapStartTimeMs = 0
Expand Down Expand Up @@ -1783,6 +1817,7 @@ class MainActivity : AppCompatActivity() {
outState.putFloatArray(KEY_LIVE_SPEED_POINTS, liveSpeedPoints.toFloatArray())
outState.putFloatArray(KEY_LIVE_PACE_POINTS, livePaceSecondaryPoints.toFloatArray())
outState.putFloatArray(KEY_LIVE_HR_POINTS, liveHrPoints.toFloatArray())
outState.putIntArray(KEY_LIVE_KM_MARKS, liveKmMarkIndices.toIntArray())
outState.putInt(KEY_LIVE_CHART_ELAPSED, liveChartElapsedSec)
}

Expand Down Expand Up @@ -1812,6 +1847,7 @@ class MainActivity : AppCompatActivity() {
state.getFloatArray(KEY_LIVE_SPEED_POINTS)?.let { liveSpeedPoints.addAll(it.toList()) }
state.getFloatArray(KEY_LIVE_PACE_POINTS)?.let { livePaceSecondaryPoints.addAll(it.toList()) }
state.getFloatArray(KEY_LIVE_HR_POINTS)?.let { liveHrPoints.addAll(it.toList()) }
state.getIntArray(KEY_LIVE_KM_MARKS)?.let { liveKmMarkIndices.addAll(it.toList()) }

// After process death (or screen rotation) the BLE link is gone, so restore
// active sessions as Disconnected (user can press Stop to finalise) and
Expand Down Expand Up @@ -2019,6 +2055,9 @@ class MainActivity : AppCompatActivity() {
private const val KEY_LIVE_SPEED_POINTS = "live_speed_points"
private const val KEY_LIVE_PACE_POINTS = "live_pace_points"
private const val KEY_LIVE_HR_POINTS = "live_hr_points"
private const val KEY_LIVE_KM_MARKS = "live_km_marks"
private const val SWIPE_MIN_DISTANCE_PX = 150f
private const val SWIPE_MIN_VELOCITY = 800f
private const val KEY_LIVE_CHART_ELAPSED = "live_chart_elapsed"

/** Device name prefixes that identify non-fitness BLE peripherals. */
Expand Down
120 changes: 65 additions & 55 deletions app/src/main/res/layout-land/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,16 @@
</LinearLayout>
</androidx.appcompat.widget.Toolbar>

<ScrollView
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:background="?android:colorBackground">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp">

Expand Down Expand Up @@ -139,10 +140,65 @@
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />

<com.google.android.material.tabs.TabLayout
android:id="@+id/viewToggleRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:tabMode="fixed"
app:tabGravity="fill"
app:tabInlineLabel="true">

<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:icon="@drawable/ic_lap"
android:text="@string/view_lap" />

<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:icon="@drawable/ic_chart"
android:text="@string/view_chart" />
</com.google.android.material.tabs.TabLayout>

<TextView
android:id="@+id/txtScanStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:paddingBottom="2dp"
android:paddingStart="2dp"
android:visibility="gone" />

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvScanResults"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="?attr/colorSurface"
android:paddingBottom="4dp"
android:visibility="gone" />

<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<FrameLayout
android:id="@+id/workoutViewContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp">

<com.google.android.material.card.MaterialCardView
android:id="@+id/lapSection"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:visibility="gone"
android:visibility="gone"
app:cardElevation="2dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:padding="12dp" android:gravity="center">
Expand Down Expand Up @@ -300,63 +356,14 @@
<com.google.android.material.card.MaterialCardView
android:id="@+id/chartSection"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:visibility="gone"
android:visibility="gone"
app:cardElevation="2dp">
<net.duhowpi.ftmsbridge.LineChartView
android:id="@+id/liveChart"
android:layout_width="match_parent"
android:layout_height="160dp" />
</com.google.android.material.card.MaterialCardView>

<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/viewToggleRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="4dp"
android:visibility="gone"
app:singleSelection="true"
app:selectionRequired="true"
app:checkedButton="@id/btnViewLap">

<com.google.android.material.button.MaterialButton
android:id="@+id/btnViewLap"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_weight="1"
android:text="@string/view_lap"
android:textSize="11sp"
app:icon="@drawable/ic_lap"
app:iconSize="16dp" />

<com.google.android.material.button.MaterialButton
android:id="@+id/btnViewChart"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_weight="1"
android:text="@string/view_chart"
android:textSize="11sp"
app:icon="@drawable/ic_chart"
app:iconSize="16dp" />
</com.google.android.material.button.MaterialButtonToggleGroup>

<TextView
android:id="@+id/txtScanStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="11sp"
android:paddingBottom="2dp"
android:paddingStart="2dp"
android:visibility="gone" />

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvScanResults"
android:layout_width="match_parent"
android:layout_height="160dp"
android:background="?attr/colorSurface"
android:paddingBottom="4dp"
android:visibility="gone" />
</FrameLayout>

<LinearLayout
android:id="@+id/metricsSection"
Expand Down Expand Up @@ -615,8 +622,11 @@
app:iconSize="16dp" />
</LinearLayout>

</LinearLayout>
</ScrollView>

</LinearLayout>
</ScrollView>
</LinearLayout>

<com.google.android.material.button.MaterialButton
android:id="@+id/btnWorkoutStop"
Expand Down
Loading