This article documents in detail the complete development process of a full-featured sports and health app, “Heart Sync.” The project covers core features such as GPS positioning and track tracking, Bluetooth heart-rate strap connection, HRV (heart rate variability) analysis, smart pause/resume, weight/blood pressure/blood glucose health management, user authentication and data sync, and GPX/TCX data export.
/** * GPS track point */ dataclassGpsPoint( val latitude: Double, val longitude: Double, val altitude: Double, val timestamp: Instant, val accuracy: Float, val speed: Float = 0f, val heartRate: Int = 0 )
/** * Running session data */ dataclassRunningSession( val id: String = UUID.randomUUID().toString(), val startTime: Instant, val endTime: Instant? = null, val gpsPoints: List<GpsPoint> = emptyList(), val totalDistanceMeters: Double = 0.0, val totalDurationSeconds: Long = 0, val averageSpeed: Float = 0f, val maxSpeed: Float = 0f, val averageHeartRate: Int = 0, val maxHeartRate: Int = 0, val calories: Int = 0, val cityName: String = "", val activityType: String = "cycling", val totalAscent: Double = 0.0, val thumbnailPath: String = "" )
/** * HRV measurement data */ dataclassHrvData( val id: String = UUID.randomUUID().toString(), val timestamp: Instant = Instant.now(), val sdnn: Int, // standard deviation of NN intervals val rmssd: Int, // root mean square of successive differences val rrIntervals: List<Int>, // raw RR interval data val duration: Int// measurement duration (seconds) )
/** * Health record base class */ sealedclassHealthRecord { abstractval id: String abstractval timestamp: Instant }
/** * Weight record */ dataclassWeightRecord( overrideval id: String = UUID.randomUUID().toString(), overrideval timestamp: Instant = Instant.now(), val weightKg: Double, val bodyFatPercent: Double? = null, val source: String = "manual"// manual/bluetooth ) : HealthRecord()
/** * Blood pressure record */ dataclassBloodPressureRecord( overrideval id: String = UUID.randomUUID().toString(), overrideval timestamp: Instant = Instant.now(), val systolic: Int, // systolic pressure (high) val diastolic: Int, // diastolic pressure (low) val pulse: Int, // pulse val source: String = "manual" ) : HealthRecord()
/** * Blood glucose record */ dataclassBloodGlucoseRecord( overrideval id: String = UUID.randomUUID().toString(), overrideval timestamp: Instant = Instant.now(), val value: Double, // mmol/L val measurementType: String, // fasting/after_meal/before_meal/bedtime/random val source: String = "manual" ) : HealthRecord()
2. Tech Stack Selection and Dependency Configuration
Raw GPS data has noise and jumps; a Kalman filter can effectively smooth the track:
/** * Kalman filter - smooths GPS data * * Principle: dynamically adjust the filter gain based on GPS accuracy * - High accuracy (small accuracy): large gain, trust the new data more * - Low accuracy (large accuracy): small gain, trust the historical data more */ privatefunapplyKalmanFilter(lat: Double, lng: Double, accuracy: Float): Pair<Double, Double> { // First fix returns directly if (lastFilteredLat == null || lastFilteredLng == null) { lastFilteredLat = lat lastFilteredLng = lng return Pair(lat, lng) }
// Dynamically adjust the Kalman gain based on accuracy kalmanGain = when { accuracy <= 5f -> 0.8// High-accuracy GPS, high trust accuracy <= 10f -> 0.5 accuracy <= 20f -> 0.3 accuracy <= 50f -> 0.2 else -> 0.1// Low accuracy, low trust }
// Filter formula: new value = old value + gain × (observed value - old value) val filteredLat = lastFilteredLat!! + kalmanGain * (lat - lastFilteredLat!!) val filteredLng = lastFilteredLng!! + kalmanGain * (lng - lastFilteredLng!!)
privatefunaddGpsPoint(point: GpsPoint) { // GPS warm-up period: discard the first 5 GPS points gpsWarmupCounter++ if (gpsWarmupCounter <= GPS_WARMUP_POINTS) { Log.d(TAG, "GPS warmup: skipping point $gpsWarmupCounter/$GPS_WARMUP_POINTS") return }
// Distance jump detection: a distance >50m between two points is judged a drift if (currentPoints.isNotEmpty()) { val prevPoint = currentPoints.last() val distance = calculateDistance(prevPoint, point)
val mapView = remember { MapView(context) } var aMap by remember { mutableStateOf<AMap?>(null) } var polyline by remember { mutableStateOf<Polyline?>(null) }
// Update the track LaunchedEffect(gpsPoints, aMap) { val map = aMap ?: return@LaunchedEffect
if (gpsPoints.isEmpty()) { polyline?.remove() return@LaunchedEffect }
val latLngList = gpsPoints.map { LatLng(it.latitude, it.longitude) }
// Create or update the track line if (polyline == null) { val options = PolylineOptions() .addAll(latLngList) .width(12f) .color(0xFF4CAF50.toInt()) // green .geodesic(true) polyline = map.addPolyline(options)
// Auto-zoom to the track extent if (!isRunning && latLngList.size >= 2) { val bounds = LatLngBounds.Builder() .apply { latLngList.forEach { include(it) } } .build() map.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 80)) } } else { polyline?.points = latLngList } }
privateval bluetoothAdapter: BluetoothAdapter? by lazy { val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager bluetoothManager.adapter }
privatevar bluetoothGatt: BluetoothGatt? = null
privateval _heartRate = MutableStateFlow(0) val heartRate: StateFlow<Int> = _heartRate.asStateFlow()
overridefunonServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { // Get the heart rate service val service = gatt.getService(UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")) val characteristic = service?.getCharacteristic( UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") )
if (characteristic != null) { // Enable notifications gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") )
/** * Parse heart rate data * * Data format (per the Bluetooth spec): * Byte 0: Flags * - Bit 0: heart rate value format (0=UINT8, 1=UINT16) * - Bit 3: energy-expenditure present flag * - Bit 4: RR-interval present flag * Byte 1(-2): heart rate value * Byte N+: RR intervals (2 bytes each, unit 1/1024 second) */ privatefunprocessHeartRateData(data: ByteArray) { if (data.isEmpty()) return
val flag = data[0].toInt()
// Parse the heart rate value val heartRate = if ((flag and 0x01) != 0) { // UINT16 format ((data[2].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF) } else { // UINT8 format data[1].toInt() and 0xFF } _heartRate.value = heartRate
// Parse RR intervals (for HRV calculation) if ((flag and 0x10) != 0) { var offset = if ((flag and 0x01) != 0) 3else2 if ((flag and 0x08) != 0) offset += 2// skip the energy-expenditure field
while (offset + 2 <= data.size) { val rrRaw = ((data[offset + 1].toInt() and 0xFF) shl 8) or (data[offset].toInt() and 0xFF) val rrMs = (rrRaw * 1000) / 1024// convert to milliseconds _rrIntervals.tryEmit(rrMs) offset += 2 } } }
privateval bluetoothAdapter: BluetoothAdapter? by lazy { val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager bluetoothManager.adapter }
overridefunonServicesDiscovered(gatt: BluetoothGatt, status: Int) { // Different brands of scales use different services and characteristics // Common ones: 0x181B (Body Composition Service) or custom services val service = gatt.getService(UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb")) val characteristic = service?.getCharacteristic( UUID.fromString("00002a9c-0000-1000-8000-00805f9b34fb") )
privatefunparseWeightData(data: ByteArray) { // Parse according to the specific protocol; here a common format is used as an example if (data.size >= 4) { // Assuming data format: Flags(1 byte) + Weight(2-3 bytes) val flags = data[0].toInt() val isStable = (flags and 0x01) != 0
val weightRaw = ((data[2].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF) val weightKg = weightRaw / 100.0// assume the unit is 0.01kg
Heart rate variability (HRV) refers to the degree of variation in the time intervals between consecutive heartbeats. HRV is an important indicator for assessing autonomic nervous system function and cardiovascular health.
Common HRV metrics:
SDNN: standard deviation of all RR intervals, reflecting overall heart rate variability
RMSSD: root mean square of successive RR-interval differences, mainly reflecting parasympathetic activity
pNN50: the proportion of successive RR-interval differences exceeding 50ms
privateval _hrvRecords = MutableStateFlow<List<HrvData>>(emptyList()) val hrvRecords: StateFlow<List<HrvData>> = _hrvRecords.asStateFlow()
init { loadRecords() }
funsaveHrvRecord(data: HrvData) { val currentRecords = _hrvRecords.value.toMutableList() currentRecords.add(0, data) // add to the head of the list
// Keep only the most recent 100 records if (currentRecords.size > 100) { currentRecords.removeAt(currentRecords.size - 1) }
_hrvRecords.value = currentRecords
// Persist val json = gson.toJson(currentRecords) prefs.edit().putString("hrv_records", json).apply() }
/** * Get HRV trend data * @param days number of days */ fungetHrvTrend(days: Int): List<HrvTrendPoint> { val cutoffTime = Instant.now().minus(days.toLong(), ChronoUnit.DAYS)
privatefuncalculateBmi(weightKg: Double) { // Get the height from user configuration val heightM = repository.getUserHeightCm() / 100.0 if (heightM > 0) { _bmi.value = weightKg / (heightM * heightM) } }
privateval _glucoseRecords = MutableStateFlow<List<BloodGlucoseRecord>>(emptyList()) val glucoseRecords: StateFlow<List<BloodGlucoseRecord>> = _glucoseRecords.asStateFlow()
funaddGlucoseRecord(value: Double, measurementType: String) { viewModelScope.launch { val record = BloodGlucoseRecord( value = value, measurementType = measurementType ) repository.saveBloodGlucoseRecord(record) } }
/** * Get the blood glucose category */ fungetGlucoseCategory(value: Double, measurementType: String): GlucoseCategory { returnwhen (measurementType) { "fasting" -> when { value < 3.9 -> GlucoseCategory.LOW value < 6.1 -> GlucoseCategory.NORMAL value < 7.0 -> GlucoseCategory.ELEVATED else -> GlucoseCategory.HIGH } "after_meal" -> when { value < 3.9 -> GlucoseCategory.LOW value < 7.8 -> GlucoseCategory.NORMAL value < 11.1 -> GlucoseCategory.ELEVATED else -> GlucoseCategory.HIGH } else -> GlucoseCategory.UNKNOWN } }
enumclassGlucoseCategory(val label: String, val color: Color, val description: String) { LOW("Low", Color.Blue, "Blood glucose too low, please replenish sugar promptly"), NORMAL("Normal", Color.Green, "Blood glucose is within the normal range"), ELEVATED("High", Color.Yellow, "Blood glucose elevated, mind your diet"), HIGH("Very high", Color.Red, "Blood glucose too high, consider consulting a doctor"), UNKNOWN("Unknown", Color.Gray, "") } }
fungetPaceMinPerKm(): String { if (totalDistanceMeters == 0.0) return"--:--"
// Pace = total time (seconds) / distance (kilometers) val paceSeconds = totalDurationSeconds / (totalDistanceMeters / 1000.0) val minutes = (paceSeconds / 60).toInt() val seconds = (paceSeconds % 60).toInt()
dataclassHeartRateZone( val name: String, val minHr: Int, val maxHr: Int, val color: Color )
dataclassHeartRateZones( val zone1: HeartRateZone, val zone2: HeartRateZone, val zone3: HeartRateZone, val zone4: HeartRateZone, val zone5: HeartRateZone ) { fungetZoneForHeartRate(hr: Int): HeartRateZone { returnwhen { hr <= zone1.maxHr -> zone1 hr <= zone2.maxHr -> zone2 hr <= zone3.maxHr -> zone3 hr <= zone4.maxHr -> zone4 else -> zone5 } } }
9. Smart Pause and Auto-Resume
9.1 Feature Description
During cycling, the app implements smart pause and auto-resume:
Auto pause: automatically pauses when speed < 0.5m/s for 10 seconds
Auto resume: automatically resumes when speed ≥ 0.5m/s for 5 seconds after a pause
GPS track continuity during pause: keeps recording GPS points during a pause (keeping the track continuous) but doesn’t count them toward distance or duration
9.2 Implementation Code
// Timer for zero speed (used for auto pause) privatevar zeroSpeedCounter = 0 privateval AUTO_PAUSE_THRESHOLD = 10// auto pause after speed is 0 for 10 seconds
// Timer for non-zero speed (used for auto resume) privatevar nonZeroSpeedCounter = 0 privateval AUTO_RESUME_THRESHOLD = 5// auto resume after speed is non-zero for 5 seconds
/** * Detect whether auto pause or auto resume is needed */ privatefuncheckAutoPause(speed: Float) { if (speed < 0.5f) { // speed below 0.5m/s is treated as stationary zeroSpeedCounter++ nonZeroSpeedCounter = 0// reset the non-zero speed counter if (zeroSpeedCounter >= AUTO_PAUSE_THRESHOLD && !_isPaused.value) { Log.d(TAG, "Auto pause triggered: speed=0 for ${zeroSpeedCounter}s") pauseRiding() } } else { zeroSpeedCounter = 0 // While paused, check whether auto resume is needed if (_isPaused.value) { nonZeroSpeedCounter++ Log.d(TAG, "Non-zero speed detected while paused: ${nonZeroSpeedCounter}/${AUTO_RESUME_THRESHOLD}s") if (nonZeroSpeedCounter >= AUTO_RESUME_THRESHOLD) { Log.d(TAG, "Auto resume triggered: speed>0 for ${nonZeroSpeedCounter}s") resumeRiding() } } else { nonZeroSpeedCounter = 0 } } }
// Calculate the pause duration and accumulate it pauseStartTime?.let { pausedDuration += Duration.between(it, Instant.now()).seconds }
_isPaused.value = false pauseStartTime = null zeroSpeedCounter = 0 nonZeroSpeedCounter = 0// reset the non-zero speed counter vibrate(200) // vibrate to signal resume }
9.3 GPS Track Handling During Pause
locationFlow.collect { gpsPoint -> // Attach the heart rate to the GPS point val pointWithHeartRate = gpsPoint.copy(heartRate = _currentHeartRate.value) _currentLocation.value = pointWithHeartRate _currentSpeed.value = gpsPoint.speed
// Detect speed for auto pause/resume (detect regardless of pause state) checkAutoPause(gpsPoint.speed)
// Also record GPS points while paused (to keep the track continuous), but don't count distance if (_isPaused.value) { // While paused, only record the GPS point's location, without computing distance addGpsPointWithoutDistance(pointWithHeartRate) } else { // During normal activity, record the GPS point and compute distance addGpsPoint(pointWithHeartRate) } }
/** * Sync records from the server */ funsyncFromServer() { if (!RetrofitClient.hasToken()) return
scope.launch { try { val response = api.getSessions(page = 1, pageSize = 100) if (response.isSuccessful) { val apiResponse = response.body() if (apiResponse?.code == 0 && apiResponse.data != null) { val serverRecords = apiResponse.data.sessions.map { it.toRunningSession() }
// Merge local and server records val localRecords = _records.value val localRecordsMap = localRecords.associateBy { it.id } val serverIds = serverRecords.map { it.id }.toSet()
// Upload records unique to the local side to the server val localOnlyRecords = localRecords.filter { it.id !in serverIds } localOnlyRecords.forEach { record -> uploadToServer(record) }
// Merge records: if the local side has GPS data, prefer the local version val mergedServerRecords = serverRecords.map { serverRecord -> val localRecord = localRecordsMap[serverRecord.id] if (localRecord != null && localRecord.gpsPoints.isNotEmpty() && serverRecord.gpsPoints.isEmpty()) { serverRecord.copy(gpsPoints = localRecord.gpsPoints) } else { serverRecord } }
评论
0 条评论