Browse Source

KMP project init

main
AnranYus 1 month ago
commit
e8644c9d7a
  1. 18
      .gitignore
  2. 14
      README.md
  3. 10
      build.gradle.kts
  4. 135
      composeApp/build.gradle.kts
  5. BIN
      composeApp/keystore.jks
  6. BIN
      composeApp/libs/NexRingSDK_v1.4.0_release.aar
  7. BIN
      composeApp/libs/OemAuth_v2.0.0_release.aar
  8. BIN
      composeApp/libs/SleepStagingNativeLib_v5_ring_release_v2.5.6.1.aar
  9. 97
      composeApp/src/androidMain/AndroidManifest.xml
  10. 32
      composeApp/src/androidMain/kotlin/com/whitefish/app/Application.kt
  11. 37
      composeApp/src/androidMain/kotlin/com/whitefish/app/BaseActivity.kt
  12. 52
      composeApp/src/androidMain/kotlin/com/whitefish/app/BaseAdapter.kt
  13. 45
      composeApp/src/androidMain/kotlin/com/whitefish/app/BaseFragment.kt
  14. 6
      composeApp/src/androidMain/kotlin/com/whitefish/app/BaseViewModel.kt
  15. 9
      composeApp/src/androidMain/kotlin/com/whitefish/app/Platform.android.kt
  16. 7
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/AnimalType.kt
  17. 5
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/BiologicalClock.kt
  18. 14
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Device.kt
  19. 10
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Evaluate.kt
  20. 10
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Exercise.kt
  21. 8
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/RecoverySleep.kt
  22. 6
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepData.kt
  23. 101
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepState.kt
  24. 29
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UIRecoveryState.kt
  25. 13
      composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UserInfo.kt
  26. 15
      composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleDevice.kt
  27. 422
      composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleManager.kt
  28. 8
      composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleConnectionListener.kt
  29. 10
      composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleScanCallback.kt
  30. 15
      composeApp/src/androidMain/kotlin/com/whitefish/app/chart/DateValueFormatter.kt
  31. 233
      composeApp/src/androidMain/kotlin/com/whitefish/app/chart/GradientLineChartRenderer.kt
  32. 40
      composeApp/src/androidMain/kotlin/com/whitefish/app/chart/ResizedDrawable.java
  33. 13
      composeApp/src/androidMain/kotlin/com/whitefish/app/chart/RightAlignedValueFormatter.java
  34. 171
      composeApp/src/androidMain/kotlin/com/whitefish/app/chart/VerticalRadiusBarChartRender.kt
  35. 6
      composeApp/src/androidMain/kotlin/com/whitefish/app/constants/Constants.kt
  36. 16
      composeApp/src/androidMain/kotlin/com/whitefish/app/dao/HeartRateDao.kt
  37. 17
      composeApp/src/androidMain/kotlin/com/whitefish/app/dao/SleepDao.kt
  38. 18
      composeApp/src/androidMain/kotlin/com/whitefish/app/dao/WorkoutDao.kt
  39. 13
      composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/HeartRateDataBean.kt
  40. 21
      composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/SleepDataBean.kt
  41. 43
      composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/WorkoutDbData.kt
  42. 21
      composeApp/src/androidMain/kotlin/com/whitefish/app/dao/converter/SleepSegmentConverter.kt
  43. 280
      composeApp/src/androidMain/kotlin/com/whitefish/app/data/FakeRepository.kt
  44. 39
      composeApp/src/androidMain/kotlin/com/whitefish/app/db/AppDatabase.kt
  45. 237
      composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceDataProvider.kt
  46. 202
      composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceManager.kt
  47. 220
      composeApp/src/androidMain/kotlin/com/whitefish/app/device/ECGViewModel.kt
  48. 60
      composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Chart.kt
  49. 68
      composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Lifecycle.kt
  50. 8
      composeApp/src/androidMain/kotlin/com/whitefish/app/ext/List.kt
  51. 143
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/connect/ConnectTipActivity.kt
  52. 207
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DeviceActivity.kt
  53. 100
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesAdapter.kt
  54. 61
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesViewModel.kt
  55. 206
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeActivity.kt
  56. 39
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeViewModel.kt
  57. 607
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/StateAdapter.kt
  58. 5
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/BloodOxygen.kt
  59. 7
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseHistory.kt
  60. 3
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseTarget.kt
  61. 20
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HalfRowBarChart.kt
  62. 10
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeardRate.kt
  63. 9
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeartHealth.kt
  64. 8
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryLineChart.kt
  65. 11
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryScore.kt
  66. 3
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Row.kt
  67. 16
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Setting.kt
  68. 9
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Temperature.kt
  69. 5
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Tips.kt
  70. 83
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/AddExerciseDialog.kt
  71. 54
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/DialogExerciseViewModel.kt
  72. 43
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseAdapter.kt
  73. 98
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseDialog.kt
  74. 91
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseFragment.kt
  75. 112
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseViewModel.kt
  76. 64
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SelectDialog.kt
  77. 106
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SetTargetDialog.kt
  78. 36
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/DateAdapter.kt
  79. 353
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryAdapter.kt
  80. 64
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryFragment.kt
  81. 117
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryViewModel.kt
  82. 112
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingAdapter.kt
  83. 112
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingFragment.kt
  84. 14
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingViewModel.kt
  85. 121
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateFragment.kt
  86. 90
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateViewModel.kt
  87. 26
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/launcher/LauncherActivity.kt
  88. 34
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/login/LoginActivity.kt
  89. 32
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanActivity.kt
  90. 6
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanViewModel.kt
  91. 105
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceActivity.kt
  92. 93
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceViewModel.kt
  93. 28
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeAdapter.kt
  94. 95
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeFragment.kt
  95. 53
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/PromoteFragment.kt
  96. 44
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindFragment.kt
  97. 6
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindViewModel.kt
  98. 80
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/SelectAdapter.kt
  99. 50
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/TargetFragment.kt
  100. 40
      composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/WeightFragment.kt

18
.gitignore

@ -0,0 +1,18 @@
*.iml
.kotlin
.gradle
**/build/
xcuserdata
!src/**/build/
local.properties
.idea
.DS_Store
captures
.externalNativeBuild
.cxx
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings

14
README.md

@ -0,0 +1,14 @@
This is a Kotlin Multiplatform project targeting Android, iOS.
* `/composeApp` is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- `commonMain` is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
`iosMain` would be the right folder for such calls.
* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…

10
build.gradle.kts

@ -0,0 +1,10 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}

135
composeApp/build.gradle.kts

@ -0,0 +1,135 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
id("com.google.devtools.ksp")
id("kotlin-parcelize")
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtimeCompose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
android {
namespace = "com.whitefish.app"
compileSdk = 35
defaultConfig {
applicationId = "com.whitefish.app"
minSdk = 27
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
storeFile = file("keystore.jks")
storePassword = "smart_ring"
keyAlias = "ring"
keyPassword = "smart_ring"
}
}
buildFeatures {
dataBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildTypes {
debug {
signingConfig = signingConfigs.findByName("release")
}
release {
isMinifyEnabled = true
signingConfig = signingConfigs.findByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
dependencies {
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation("com.github.ome450901:SimpleRatingBar:1.5.1")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.room.ktx)
implementation("com.google.code.gson:gson:2.10.1")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation(libs.immersionbar)
implementation(libs.immersionbar.ktx)
implementation(libs.glide)
implementation(libs.shadowLayout)
implementation(libs.mpandroidchart)
implementation(libs.androidx.fragment.ktx)
implementation(libs.flexbox)
implementation(libs.utilcodex)
implementation(libs.logger)
implementation(fileTree("libs"))
implementation(project(":ecgAlgo"))
implementation(libs.android.database.sqlcipher)
}
}
dependencies {
implementation(libs.lifecycle.viewmodel.compose)
debugImplementation(compose.uiTooling)
}

BIN
composeApp/keystore.jks

Binary file not shown.

BIN
composeApp/libs/NexRingSDK_v1.4.0_release.aar

Binary file not shown.

BIN
composeApp/libs/OemAuth_v2.0.0_release.aar

Binary file not shown.

BIN
composeApp/libs/SleepStagingNativeLib_v5_ring_release_v2.5.6.1.aar

Binary file not shown.

97
composeApp/src/androidMain/AndroidManifest.xml

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 蓝牙部分:对于低于Android 12的权限申请 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<!-- 蓝牙部分:对于高于Android 12的权限申请 -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission
android:name="android.permission.BLUETOOTH_CONNECT"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".Application"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RingApp"
tools:targetApi="31">
<activity
android:name=".feature.launcher.LauncherActivity"
android:exported="true">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
<activity
android:name=".feature.login.LoginActivity"
android:exported="false" />
<activity
android:name=".feature.connect.ConnectTipActivity"
android:exported="false" />
<activity
android:name=".feature.userInfo.UserInfoActivity"
android:exported="false">
</activity>
<activity
android:name=".feature.devices.DeviceActivity"
android:exported="false" />
<activity
android:name=".feature.home.HomeActivity"
android:exported="false" />
<activity
android:name=".feature.running.RunningActivity"
android:exported="false" />
<activity
android:name=".feature.running.RunningHintActivity"
android:exported="false" />
<activity
android:name=".feature.preference.PreferenceActivity"
android:exported="true" />
<activity
android:name=".feature.plan.PlanActivity"
android:exported="false" />
<activity
android:name=".feature.sleep.SleepDetailActivity"
android:exported="false" />
<activity
android:name=".feature.sleep.SleepTypeActivity"
android:exported="false" />
<activity
android:name=".feature.recovery.HrvAssessmentActivity"
android:exported="false" >
</activity>
</application>
</manifest>

32
composeApp/src/androidMain/kotlin/com/whitefish/app/Application.kt

@ -0,0 +1,32 @@
package com.whitefish.app
import android.annotation.SuppressLint
import android.app.Application
import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import lib.linktop.nexring.api.NexRingManager
import com.whitefish.app.bt.BleManager
import com.whitefish.app.utils.ActivityLifecycleCb
class Application : Application() {
val bleManager by lazy {
NexRingManager.init(this)
BleManager(this)
}
val mActivityLifecycleCb = ActivityLifecycleCb()
companion object {
@SuppressLint("StaticFieldLeak")
var INSTANTS: com.whitefish.app.Application? = null
private set
}
override fun onCreate() {
super.onCreate()
INSTANTS = this
Logger.addLogAdapter(AndroidLogAdapter())
registerActivityLifecycleCallbacks(mActivityLifecycleCb)
}
}

37
composeApp/src/androidMain/kotlin/com/whitefish/app/BaseActivity.kt

@ -0,0 +1,37 @@
package com.whitefish.app
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.gyf.immersionbar.ImmersionBar
abstract class BaseActivity<VB : ViewDataBinding, VM : ViewModel> : AppCompatActivity() {
protected val mBinding: VB by lazy {
DataBindingUtil.setContentView(
this, setLayout()
)
}
protected val mViewModel: VM by lazy {
ViewModelProvider(this)[setViewModel()]
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(mBinding.root)
//状态栏透明
ImmersionBar.with(this).transparentBar().init()
bind()
}
abstract fun setLayout(): Int
abstract fun setViewModel(): Class<VM>
abstract fun bind()
}

52
composeApp/src/androidMain/kotlin/com/whitefish/app/BaseAdapter.kt

@ -0,0 +1,52 @@
package com.whitefish.app
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
abstract class BaseAdapter<T, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
val data = arrayListOf<T>()
open fun setData(newData: List<T>, diffUpdate: Boolean = true) {
if (diffUpdate) {
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = true
override fun getOldListSize() = data.size
override fun getNewListSize() = newData.size
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
data[oldItemPosition] == newData[newItemPosition]
})
data.clear()
data.addAll(newData)
diffResult.dispatchUpdatesTo(this)
} else {
data.clear()
data.addAll(newData)
notifyDataSetChanged()
}
}
fun getItem(position: Int): T {
if (position >= data.size) {
return data.last()
}
return data[position]
}
fun resetItem(index: Int, itemData: T) {
if (index == -1) {
return
}
if (index > data.size - 1) {
return
}
data[index] = itemData
notifyItemChanged(index, itemData)
}
override fun getItemCount(): Int = data.size
}

45
composeApp/src/androidMain/kotlin/com/whitefish/app/BaseFragment.kt

@ -0,0 +1,45 @@
package com.whitefish.app
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
abstract class BaseFragment<VB:ViewDataBinding,VM: ViewModel>:Fragment() {
protected lateinit var mViewModel: VM
private var _binding: VB? = null
protected val mBinding: VB
get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
mViewModel = setViewModel()
_binding = DataBindingUtil.inflate(inflater,setLayout(),container,false)
return _binding!!.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bind()
}
override fun onDestroyView() {
super.onDestroyView()
//避免内存泄漏
_binding = null
}
abstract fun bind()
abstract fun setLayout():Int
protected abstract fun setViewModel(): VM
}

6
composeApp/src/androidMain/kotlin/com/whitefish/app/BaseViewModel.kt

@ -0,0 +1,6 @@
package com.whitefish.app
import androidx.lifecycle.ViewModel
abstract class BaseViewModel:ViewModel() {
}

9
composeApp/src/androidMain/kotlin/com/whitefish/app/Platform.android.kt

@ -0,0 +1,9 @@
package com.whitefish.app
import android.os.Build
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = AndroidPlatform()

7
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/AnimalType.kt

@ -0,0 +1,7 @@
package com.whitefish.app.bean
data class AnimalType (
val animalName:String,
val coverUrl: Int,
val lastUpdate:String,
)

5
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/BiologicalClock.kt

@ -0,0 +1,5 @@
package com.whitefish.app.bean
data class BiologicalClock(
val tip: String
)

14
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Device.kt

@ -0,0 +1,14 @@
package com.whitefish.app.bean
import android.annotation.SuppressLint
import com.whitefish.app.bt.BleDevice
data class Device(
val name:String,
val address:String,
)
@SuppressLint("MissingPermission")
fun BleDevice.toDevice(): Device{
return Device(name = device.name, address = device.address)
}

10
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Evaluate.kt

@ -0,0 +1,10 @@
package com.whitefish.app.bean
data class Evaluate (
val score:Int,
val progress:Int,
val time:Int,
val benefit:Int,
val exercise:Int,
val efficiency:Int
)

10
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Exercise.kt

@ -0,0 +1,10 @@
package com.whitefish.app.bean
data class Exercise(
val userName:String,
val userHeader:String,
val consume:Int,
val target:Int,
val exerciseState:List<Any>
)

8
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/RecoverySleep.kt

@ -0,0 +1,8 @@
package com.whitefish.app.bean
data class RecoverySleep(
val percentage: String,
val percentageAvg:String,
val totalTime:String,
val totalTimeAvg:String,
)

6
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepData.kt

@ -0,0 +1,6 @@
package com.whitefish.app.bean
data class SleepData(
val timeQuantum:Int, //数据持续时间(单位为 min)
val state:Int ,
)

101
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepState.kt

@ -0,0 +1,101 @@
package com.whitefish.app.bean
import android.os.Parcelable
import com.whitefish.app.dao.bean.SleepDataBean
import com.whitefish.app.view.SleepSegment
import kotlinx.parcelize.Parcelize
import lib.linktop.nexring.api.SLEEP_STATE_DEEP
import lib.linktop.nexring.api.SLEEP_STATE_LIGHT
import lib.linktop.nexring.api.SLEEP_STATE_REM
import lib.linktop.nexring.api.SLEEP_STATE_WAKE
import java.util.Calendar
@Parcelize
data class SleepState(
val date: String = "",
val totalTime: Long = 0L,
val sleepChartData: List<SleepSegment> = emptyList(),
val score: String = "",
val rating: Float = 0f,
var deepSleepTime: String = "",
var lightSleepTime: String = "",
var remSleepTime: String = "",
var wakeTime:String = ""
) : Parcelable{
init {
val stateMap = getSleepStateTime()
// 将分钟数转换为xx小时xx分钟格式的辅助函数
fun formatMinutesToHourMinute(minutes: Float): String {
val totalMinutes = minutes.toInt()
val hours = totalMinutes / 60
val remainingMinutes = totalMinutes % 60
return if (hours > 0) {
"${hours}小时${remainingMinutes}分钟"
} else {
"${remainingMinutes}分钟"
}
}
// 为各个睡眠阶段字段赋值
deepSleepTime = formatMinutesToHourMinute(stateMap[SLEEP_STATE_DEEP] ?: 0f)
lightSleepTime = formatMinutesToHourMinute(stateMap[SLEEP_STATE_LIGHT] ?: 0f)
remSleepTime = formatMinutesToHourMinute(stateMap[SLEEP_STATE_REM] ?: 0f)
wakeTime = formatMinutesToHourMinute(stateMap[SLEEP_STATE_WAKE] ?: 0f)
}
}
fun SleepDataBean.toState():SleepState{
val cal = Calendar.getInstance().apply {
timeInMillis = date
}
return SleepState(
"${cal.get(Calendar.MONTH) + 1}/${cal.get(Calendar.DAY_OF_MONTH)}",
this.totalSleepTime,
this.sleepSegments,
)
}
//获取每个睡眠阶段的总分钟数
fun SleepState.getSleepStateTime():HashMap<Int,Float>{
var deepTime = 0F
var lightTime = 0F
var remTime = 0F
var wakeTime = 0F
this.sleepChartData.forEach {
when(it.state){
SLEEP_STATE_WAKE -> {
wakeTime += it.durationMinutes
}
SLEEP_STATE_REM -> {
remTime += it.durationMinutes
}
SLEEP_STATE_DEEP -> {
deepTime += it.durationMinutes
}
SLEEP_STATE_LIGHT -> {
lightTime += it.durationMinutes
}
}
}
return hashMapOf<Int, Float>().apply {
this[SLEEP_STATE_WAKE] = wakeTime
this[SLEEP_STATE_REM] = remTime
this[SLEEP_STATE_DEEP] = deepTime
this[SLEEP_STATE_LIGHT] = lightTime
}
}
/**
*
* @param start
* @param end
* @param duration
* */
class SleepNap(
val start: Float = 0f,
val end: Float = 0f,
val duration: Long = 0L,
)

29
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UIRecoveryState.kt

@ -0,0 +1,29 @@
package com.whitefish.app.bean
import android.text.SpannableStringBuilder
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.data.BarDataSet
import com.github.mikephil.charting.data.LineDataSet
data class UIRecoveryStateItem(
val icon: Int,
val title: String,
val description: String,
val lineData: LineDataSet?,
val barData: BarDataSet?,
val value: SpannableStringBuilder,
val viewType:ViewType,
val sleepData: SleepState? = null
){
sealed class ViewType{
data object SLEEP : ViewType()
data object HEAT : ViewType()
data object OXYGEN :ViewType()
data object PRESSURE: ViewType()
data object TEMPERATURE: ViewType()
}
}
data class UIRecoveryState(
val data:List<UIRecoveryStateItem>
)

13
composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UserInfo.kt

@ -0,0 +1,13 @@
package com.whitefish.app.bean
data class UserInfo(
val sex:String? = null,
val birthday:String? = null,
val height:Double? = null,
val weight:Double? = null,
val target:String? = null,
val promote:String? = null,
val like:List<String> = emptyList(),
val wear:String? = null,
val handedness:String? = null,
)

15
composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleDevice.kt

@ -0,0 +1,15 @@
package com.whitefish.app.bt
import android.bluetooth.BluetoothDevice
data class BleDevice(
val device: BluetoothDevice,
val color: Int,
val size: Int,
val batteryState: Int? = null,
val batteryLevel: Int? = null,
/*val chipMode: Int = 0,*/
val generation: Int? = null,
val sn: String? = null,
var rssi: Int,
)

422
composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleManager.kt

@ -0,0 +1,422 @@
package com.whitefish.app.bt
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.appcompat.app.AlertDialog
import com.whitefish.app.Application
import com.whitefish.app.R
import com.whitefish.app.utils.handlerRemove
import com.whitefish.app.utils.loge
import com.whitefish.app.utils.logi
import com.whitefish.app.utils.post
import com.whitefish.app.utils.postDelay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import lib.linktop.nexring.api.NexRingBluetoothGattCallback
import lib.linktop.nexring.api.NexRingManager
import lib.linktop.nexring.api.OEM_AUTHENTICATION_FAILED_FOR_CHECK_R2
import lib.linktop.nexring.api.OEM_AUTHENTICATION_FAILED_FOR_DECRYPT
import lib.linktop.nexring.api.OEM_AUTHENTICATION_FAILED_FOR_SN_NULL
import lib.linktop.nexring.api.OEM_AUTHENTICATION_START
import lib.linktop.nexring.api.OEM_AUTHENTICATION_SUCCESS
import lib.linktop.nexring.api.matchFromAdvertisementData
import lib.linktop.nexring.api.parseScanRecord
private const val OEM_STEP_CHECK_OEM_AUTHENTICATION_STATUS = 0
private const val OEM_STEP_AUTHENTICATE_OEM = 1
private const val OEM_STEP_TIMESTAMP_SYNC = 2
private const val OEM_STEP_PROCESS_COMPLETED = 3
class BleManager(val app: Application) {
private val tag = "BleManager"
private val mBluetoothAdapter =
(app.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
private val mOnBleConnectionListeners: MutableList<OnBleConnectionListener> = ArrayList()
private var mOnBleScanCallback: OnBleScanCallback? = null
var bleGatt: BluetoothGatt? = null
private val scanDevMacList: MutableList<String> = ArrayList()
var isScanning = false
private val mScanCallback = object : ScanCallback() {
@SuppressLint("MissingPermission")
override fun onScanResult(callbackType: Int, result: ScanResult) {
super.onScanResult(callbackType, result)
// loge(
// "JKL",
// "address ${result.device.address}, scanRecord.bytes ${result.scanRecord?.bytes.toByteArrayString()}"
// )
synchronized(scanDevMacList) {
val scanRecord = result.scanRecord
if (scanRecord != null) {
val bytes = scanRecord.bytes
if (bytes.matchFromAdvertisementData()) {
val address = result.device.address
if (!scanDevMacList.contains(address).apply {
loge("scanDevMacList contains address($address) = ${!this}")
}) {
val bleDevice = bytes.parseScanRecord().run {
BleDevice(
result.device, color, size,
batteryState, batteryLevel,
/*chipMode,*/ generation, sn,
result.rssi
)
}
scanDevMacList.add(address)
mOnBleScanCallback?.apply {
post {
onScanning(bleDevice)
}
}
}
}
}
}
}
}
private val scanStopRunnable = Runnable {
cancelScan()
}
var bleState = MutableStateFlow(0)
var connectedDevice: BluetoothDevice? = null
private val _oemStepComplete = MutableStateFlow(false)
val oemStepComplete = _oemStepComplete.asStateFlow()
private val mGattCallback = object : NexRingBluetoothGattCallback(NexRingManager.get()) {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
gatt: BluetoothGatt, status: Int, newState: Int,
) {
super.onConnectionStateChange(gatt, status, newState)
loge(
tag,
"onConnectionStateChange->status:$status, newState:$newState"
)
when (newState) {
BluetoothProfile.STATE_DISCONNECTED -> {
NexRingManager.get().apply {
setBleGatt(null)
unregisterRingService()
}
connectedDevice = null
gatt.close()
bleState.value = BluetoothProfile.STATE_DISCONNECTED
postBleState()
_oemStepComplete.value = false
}
BluetoothProfile.STATE_CONNECTING -> {
bleState.value = BluetoothProfile.STATE_CONNECTING
postBleState()
}
BluetoothProfile.STATE_CONNECTED -> {
bleState.value = BluetoothProfile.STATE_CONNECTED
connectedDevice = gatt.device
postBleState()
// The default MTU for ATT in the core spec is 23 bytes, with 1 byte for ATT's Opcode, 2 bytes for ATT's Handle, and 20 bytes for GATT.
// So if you want to set 40, you should request the MTU to be set to 43.
gatt.requestMtu(40 + 3)
}
}
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
super.onMtuChanged(gatt, mtu, status)
when (status) {
BluetoothGatt.GATT_SUCCESS -> {
loge(tag, "onMtuChanged success.")
gatt.discoverServices()
}
BluetoothGatt.GATT_FAILURE -> {
loge(tag, "onMtuChanged failure.")
}
else -> loge(tag, "onMtuChanged unknown status $status.")
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
super.onServicesDiscovered(gatt, status)
loge(tag, "onServicesDiscovered(), status:${status}")
// Refresh device cache. This is the safest place to initiate the procedure.
if (status == BluetoothGatt.GATT_SUCCESS) {
NexRingManager.get().setBleGatt(gatt)
logi(tag, "onServicesDiscovered(), registerHealthData")
postDelay {
NexRingManager.get().registerRingService()
}
}
}
override fun onDescriptorWrite(
gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int,
) {
super.onDescriptorWrite(gatt, descriptor, status)
if (status == BluetoothGatt.GATT_SUCCESS &&
NexRingManager.get().isRingServiceRegistered()
) {
// post {
// //you need to synchronize the timestamp with the device first after
// //the the service registration is successful.
// NexRingManager.get()
// .settingsApi()
// .timestampSync(System.currentTimeMillis()) {
// synchronized(mOnBleConnectionListeners) {
// mOnBleConnectionListeners.forEach {
// it.onBleReady()
// }
// }
// }
// }
OemAuthenticationProcess().start()
}
}
}
@SuppressLint("MissingPermission", "ObsoleteSdkInt")
private fun connectInterval(device: BluetoothDevice) {
loge(tag, "connect gatt to ${device.address}")
bleGatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// device.connectGatt(context, false, gattCallback)
device.connectGatt(app, false, mGattCallback, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(app, false, mGattCallback)
}.apply { connect() }
}
fun isSupportBle(): Boolean =
// Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 &&
app.applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
@SuppressLint("MissingPermission")
fun startScan(timeoutMillis: Long, callback: OnBleScanCallback) {
isScanning = true
mOnBleScanCallback = callback
scanDevMacList.clear()
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
mBluetoothAdapter.bluetoothLeScanner.startScan(null, scanSettings, mScanCallback)
postDelay(scanStopRunnable, timeoutMillis)
}
@SuppressLint("MissingPermission")
fun cancelScan() {
if (isScanning) {
isScanning = false
mBluetoothAdapter.bluetoothLeScanner.stopScan(mScanCallback)
post {
mOnBleScanCallback?.onScanFinished()
mOnBleScanCallback = null
scanStopRunnable.handlerRemove()
}
}
scanDevMacList.clear()
}
@SuppressLint("MissingPermission")
fun connect(address: String): Boolean {
val remoteDevice = mBluetoothAdapter.getRemoteDevice(address)
loge("JKL", "connect to remoteDevice by address, ${remoteDevice.name}")
return if (!remoteDevice.name.isNullOrEmpty()) {
connect(remoteDevice)
true
} else {
loge("JKL", "reject, because it cannot connect success.")
false
}
}
fun connect(device: BluetoothDevice) {
val delayConnect = isScanning
cancelScan()
if (delayConnect) {
loge("JKL", "connect to ${device.address}, delay 200L")
postDelay({
loge("JKL", "delay finish, connect to ${device.address}")
connectInterval(device)
}, 200L)
} else {
loge("JKL", "connect to ${device.address} right now.")
connectInterval(device)
}
}
@SuppressLint("MissingPermission")
fun disconnect() {
bleGatt?.disconnect()
bleGatt = null
}
fun addOnBleConnectionListener(listener: OnBleConnectionListener) {
synchronized(mOnBleConnectionListeners) {
mOnBleConnectionListeners.add(listener)
}
}
fun removeOnBleConnectionListener(listener: OnBleConnectionListener) {
synchronized(mOnBleConnectionListeners) {
mOnBleConnectionListeners.remove(listener)
}
}
fun postBleState() {
post {
synchronized(mOnBleConnectionListeners) {
mOnBleConnectionListeners.forEach {
it.onBleState(bleState.value)
}
}
}
}
inner class OemAuthenticationProcess : Thread() {
private val innerTag = "OemAuthenticationProcess"
private val locked = Object()
private var step = OEM_STEP_CHECK_OEM_AUTHENTICATION_STATUS
override fun run() {
while (step < OEM_STEP_PROCESS_COMPLETED) {
sleep(200L)
synchronized(locked) {
when (step) {
OEM_STEP_CHECK_OEM_AUTHENTICATION_STATUS -> {
loge(innerTag, "OEM_STEP_CHECK_OEM_AUTHENTICATION_STATUS")
NexRingManager.get().securityApi().checkOemAuthenticationStatus {
step = if (it) OEM_STEP_AUTHENTICATE_OEM else OEM_STEP_TIMESTAMP_SYNC
synchronized(locked) {
locked.notify()
}
}
}
OEM_STEP_AUTHENTICATE_OEM -> {
loge(innerTag, "OEM_STEP_AUTHENTICATE_OEM")
NexRingManager.get().securityApi().authenticateOem { result ->
when (result) {
OEM_AUTHENTICATION_FAILED_FOR_CHECK_R2 -> {
logi(innerTag, "OEM_AUTHENTICATION_FAILED_FOR_CHECK_R2")
step = OEM_STEP_PROCESS_COMPLETED
result.showOemAuthFailDialog()
synchronized(locked) {
locked.notify()
}
}
OEM_AUTHENTICATION_FAILED_FOR_DECRYPT -> {
logi(innerTag, "OEM_AUTHENTICATION_FAILED_FOR_DECRYPT")
step = OEM_STEP_PROCESS_COMPLETED
result.showOemAuthFailDialog()
synchronized(locked) {
locked.notify()
}
}
OEM_AUTHENTICATION_FAILED_FOR_SN_NULL -> {
logi(innerTag, "OEM_AUTHENTICATION_FAILED_FOR_SN_NULL")
step = OEM_STEP_PROCESS_COMPLETED
result.showOemAuthFailDialog()
synchronized(locked) {
locked.notify()
}
}
OEM_AUTHENTICATION_START -> {
logi(innerTag, "OEM_AUTHENTICATION_START")
}
OEM_AUTHENTICATION_SUCCESS -> {
logi(innerTag, "OEM_AUTHENTICATION_SUCCESS")
step = OEM_STEP_TIMESTAMP_SYNC
synchronized(locked) {
locked.notify()
}
}
}
}
}
OEM_STEP_TIMESTAMP_SYNC -> {
loge(innerTag, "OEM_STEP_TIMESTAMP_SYNC")
NexRingManager.get()
.settingsApi()
.timestampSync(System.currentTimeMillis()) {
loge(innerTag, "OEM_STEP_TIMESTAMP_SYNC result $it")
synchronized(mOnBleConnectionListeners) {
post {
mOnBleConnectionListeners.forEach { listener ->
listener.onBleReady()
}
}
}
step = OEM_STEP_PROCESS_COMPLETED
synchronized(locked) {
locked.notify()
}
}
}
}
locked.wait()
}
}
_oemStepComplete.value = true
loge(innerTag, "OEM_STEP_PROCESS_COMPLETED")
}
}
private fun Int.showOemAuthFailDialog() {
app.mActivityLifecycleCb.currAct.apply {
if (this != null) {
val message = when (this@showOemAuthFailDialog) {
OEM_AUTHENTICATION_FAILED_FOR_SN_NULL -> {
getString(R.string.dialog_msg_oem_auth_failed_cause_by_sn_null)
}
OEM_AUTHENTICATION_FAILED_FOR_DECRYPT -> {
getString(R.string.dialog_msg_oem_auth_failed_cause_by_r1_to_r2)
}
OEM_AUTHENTICATION_FAILED_FOR_CHECK_R2 -> {
getString(R.string.dialog_msg_oem_auth_failed_cause_by_check_r2)
}
else -> "Unknown error."
}
runOnUiThread {
AlertDialog.Builder(this)
.setCancelable(false)
.setTitle(R.string.dialog_title_oem_auth_failed)
.setMessage(message)
.setPositiveButton(R.string.btn_label_disconnected) { _, _ ->
disconnect()
}.create().show()
}
} else disconnect()
}
}
}

8
composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleConnectionListener.kt

@ -0,0 +1,8 @@
package com.whitefish.app.bt
interface OnBleConnectionListener {
fun onBleState(state: Int)
fun onBleReady()
}

10
composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleScanCallback.kt

@ -0,0 +1,10 @@
package com.whitefish.app.bt
import com.whitefish.app.bt.BleDevice
interface OnBleScanCallback {
fun onScanning(result: BleDevice)
fun onScanFinished()
}

15
composeApp/src/androidMain/kotlin/com/whitefish/app/chart/DateValueFormatter.kt

@ -0,0 +1,15 @@
package com.whitefish.app.chart
import com.github.mikephil.charting.formatter.ValueFormatter
class DateValueFormatter: ValueFormatter() {
override fun getFormattedValue(value: Float): String {
val hour = value.toInt()
// 只在0,6,12,18,24小时处显示标签
return if (hour % 6 == 0) {
String.format("%02d:00", hour)
} else {
"" // 其他位置不显示
}
}
}

233
composeApp/src/androidMain/kotlin/com/whitefish/app/chart/GradientLineChartRenderer.kt

@ -0,0 +1,233 @@
package com.whitefish.app.chart
import android.graphics.Canvas
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.renderer.LineChartRenderer
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Shader
import androidx.core.content.ContextCompat
import com.github.mikephil.charting.animation.ChartAnimator
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.utils.ViewPortHandler
import com.whitefish.app.R
class GradientLineChartRenderer(
private val currentDate:Int,
private val chart: LineChart,
animator: ChartAnimator,
viewPortHandler: ViewPortHandler
) : LineChartRenderer(chart, animator, viewPortHandler) {
// 创建虚线效果的Paint对象
private val dashedLinePaint = Paint().apply {
color = chart.rootView.resources.getColor(R.color.white_25,chart.rootView.context.theme)
style = Paint.Style.STROKE
strokeWidth = 4f
isAntiAlias = true
// 设置虚线效果
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
}
override fun drawData(canvas: Canvas) {
drawVerticalDashedLines(canvas)//先绘制虚线,避免覆盖折线
super.drawData(canvas)
drawHighlightedPoint(canvas)
}
private fun drawHighlightedPoint(canvas: Canvas) {
val dataSet = mChart.data.getDataSetByIndex(0)
if (dataSet == null || dataSet.entryCount == 0) return
val highlightIndex = currentDate
if (highlightIndex >= dataSet.entryCount) return
val entry = dataSet.getEntryForIndex(highlightIndex)
val trans = mChart.getTransformer(dataSet.axisDependency)
val point = FloatArray(2)
point[0] = entry.x
point[1] = entry.y
trans.pointValuesToPixel(point)
// 使用drawable资源绘制高亮点
val drawable = ContextCompat.getDrawable(chart.context, R.drawable.ic_circle_indicator)
// 计算drawable的bounds,使其居中于数据点
val size = 40
val left = point[0].toInt() - size / 2
val top = point[1].toInt() - size / 2
val right = left + size
val bottom = top + size
drawable?.setBounds(left, top, right, bottom)
drawable?.draw(canvas)
}
private fun drawVerticalDashedLines(canvas: Canvas) {
val trans =
mChart.getTransformer(YAxis.AxisDependency.LEFT)
val contentRect = mViewPortHandler.contentRect
// 需要添加垂直虚线的X轴位置
val rangeArray = arrayListOf<Float>()
for (i in 0..chart.lineData.xMax.toInt() step (chart.lineData.xMax/5).toInt()) {
rangeArray.add(i.toFloat())
}
// 遍历每个位置,绘制垂直虚线
for (hour in rangeArray) {
// 转换X轴值到像素坐标
val pixelX = trans.getPixelForValues(hour, 0f).x.toFloat()
// 如果该位置在可视区域内,则绘制虚线
if (pixelX >= contentRect.left && pixelX <= contentRect.right) {
val path = Path()
path.moveTo(pixelX, contentRect.bottom)
// 计算该X位置对应的折线Y坐标点
val dataSet = mChart.data.getDataSetByIndex(0)
var lineY = contentRect.top
// 查找最接近该X位置的数据点
for (i in 0 until dataSet.entryCount - 1) {
val e1 = dataSet.getEntryForIndex(i)
val e2 = dataSet.getEntryForIndex(i + 1)
if (hour >= e1.x && hour <= e2.x) {
// 线性插值计算Y值
val ratio = (hour - e1.x) / (e2.x - e1.x)
val y = e1.y + ratio * (e2.y - e1.y)
// 转换为像素坐标
val valPoint = FloatArray(2)
valPoint[0] = hour
valPoint[1] = y
trans.pointValuesToPixel(valPoint)
lineY = valPoint[1]
break
}
}
path.lineTo(pixelX, lineY + 10)//设置10像素偏移,避免虚线超越折线
canvas.drawPath(path, dashedLinePaint)
}
}
}
override fun drawLinear(
canvas: Canvas,
dataSet: com.github.mikephil.charting.interfaces.datasets.ILineDataSet
) {
if (dataSet.entryCount < 2)
return
val trans = mChart.getTransformer(dataSet.axisDependency)
val entryCount = dataSet.entryCount
val isDrawSteppedEnabled = dataSet.isDrawSteppedEnabled
val phaseY = mAnimator.phaseY
// 存储点的坐标
val points = FloatArray(entryCount * 4) // 每个点需要2个值(x,y),乘以2是因为每个点有两个关联点
for (i in 0 until entryCount) {
val e = dataSet.getEntryForIndex(i) ?: continue
points[i * 4] = e.x
points[i * 4 + 1] = e.y * phaseY
if (isDrawSteppedEnabled) {
val e2 = if (i >= entryCount - 1) e else dataSet.getEntryForIndex(i + 1)
points[i * 4 + 2] = e2.x
points[i * 4 + 3] = e.y * phaseY
} else {
points[i * 4 + 2] = e.x
points[i * 4 + 3] = e.y * phaseY
}
}
trans.pointValuesToPixel(points)
mRenderPaint.style = android.graphics.Paint.Style.STROKE
mRenderPaint.strokeWidth = dataSet.lineWidth
// 创建渐变色
val colorfulColors = intArrayOf(
Color.parseColor("#407EEFFF"),
Color.parseColor("#8088FF7E"),
Color.parseColor("#BFFFEF62"),
Color.parseColor("#EB3F3F"),
)
val path = Path()
for (i in 0 until entryCount) {
if (i == 0) {
path.moveTo(points[0], points[1])
} else {
val x1 = points[(i - 1) * 4]
val y1 = points[(i - 1) * 4 + 1]
val x2 = points[i * 4]
val y2 = points[i * 4 + 1]
// 使用贝塞尔曲线来平滑线条
val cpx1 = x1 + (x2 - x1) / 2
val cpx2 = x1 + (x2 - x1) / 2
path.cubicTo(cpx1, y1, cpx2, y2, x2, y2)
}
}
// 创建线性渐变
val startX = points[0]
val endX = points[points.size - 4]
val colorfulGradient = LinearGradient(
startX, 0f,
endX, 0f,
colorfulColors,
floatArrayOf(0f, 0.25f, 0.5f,1.0f),
Shader.TileMode.CLAMP
)
mRenderPaint.shader = colorfulGradient
canvas.drawPath(path, mRenderPaint)
mRenderPaint.shader = null
//得到高亮点位
val highlightX = points[currentDate * 4]
val highlightY = points[currentDate * 4 + 1]
// 创建高亮点之后的路径
val grayPath = Path()
grayPath.moveTo(highlightX, highlightY)
// 添加高亮点之后的所有点到灰色路径
for (i in currentDate + 1 until entryCount) {
val x1 = points[(i - 1) * 4]
val y1 = points[(i - 1) * 4 + 1]
val x2 = points[i * 4]
val y2 = points[i * 4 + 1]
val cpx1 = x1 + (x2 - x1) / 2
val cpx2 = x1 + (x2 - x1) / 2
grayPath.cubicTo(cpx1, y1, cpx2, y2, x2, y2)
}
//因着色效果为颜色叠加,因此需要绘制底色
mRenderPaint.color = Color.parseColor("#ffffffff")
canvas.drawPath(grayPath, mRenderPaint)
mRenderPaint.color = Color.parseColor("#20000000")
canvas.drawPath(grayPath, mRenderPaint)
}
}

40
composeApp/src/androidMain/kotlin/com/whitefish/app/chart/ResizedDrawable.java

@ -0,0 +1,40 @@
package com.whitefish.app.chart;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import java.util.Objects;
public class ResizedDrawable extends Drawable {
private final Drawable mDrawable;
public ResizedDrawable(Context context, int resId, int width, int height) {
mDrawable = ContextCompat.getDrawable(context, resId);
Objects.requireNonNull(mDrawable).setBounds( -width, -height, width, height);
}
@Override
public void draw(@NonNull Canvas canvas) {
mDrawable.draw(canvas);
}
@Override
public void setAlpha(int alpha) {
mDrawable.setAlpha(alpha);
}
@Override
public void setColorFilter(android.graphics.ColorFilter colorFilter) {
mDrawable.setColorFilter(colorFilter);
}
@Override
public int getOpacity() {
return mDrawable.getOpacity();
}
}

13
composeApp/src/androidMain/kotlin/com/whitefish/app/chart/RightAlignedValueFormatter.java

@ -0,0 +1,13 @@
package com.whitefish.app.chart;
import com.github.mikephil.charting.data.Entry;
import com.github.mikephil.charting.formatter.ValueFormatter;
public class RightAlignedValueFormatter extends ValueFormatter {
@Override
public String getPointLabel(Entry entry) {
// 返回要显示的文本
return (int)entry.getY() + "岁";
}
}

171
composeApp/src/androidMain/kotlin/com/whitefish/app/chart/VerticalRadiusBarChartRender.kt

@ -0,0 +1,171 @@
package com.whitefish.app.chart
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import com.github.mikephil.charting.animation.ChartAnimator
import com.github.mikephil.charting.data.BarEntry
import com.github.mikephil.charting.highlight.Highlight
import com.github.mikephil.charting.interfaces.dataprovider.BarDataProvider
import com.github.mikephil.charting.interfaces.datasets.IBarDataSet
import com.github.mikephil.charting.renderer.BarChartRenderer
import com.github.mikephil.charting.utils.Utils
import com.github.mikephil.charting.utils.ViewPortHandler
import kotlin.math.min
class VerticalRadiusBarChartRender(
chart: BarDataProvider?, animator: ChartAnimator?, viewPortHandler: ViewPortHandler?
) : BarChartRenderer(chart, animator, viewPortHandler) {
private val mBarShadowRectBuffer = RectF()
private var mRadius = 0
fun setRadius(radius: Int) {
this.mRadius = radius
}
override fun drawDataSet(c: Canvas, dataSet: IBarDataSet, index: Int) {
val trans = mChart.getTransformer(dataSet.axisDependency)
mBarBorderPaint.strokeWidth = Utils.convertDpToPixel(dataSet.barBorderWidth)
val drawBorder = dataSet.barBorderWidth > 0f
val phaseX = mAnimator.phaseX
val phaseY = mAnimator.phaseY
if (mChart.isDrawBarShadowEnabled) {
val barData = mChart.barData
val barWidth = barData.barWidth
val barWidthHalf = barWidth / 2.0f
var x: Float
var i = 0
val count = min(
(dataSet.entryCount.toFloat() * phaseX).toDouble().toInt().toDouble(),
dataSet.entryCount.toDouble()
)
while (i < count) {
val e = dataSet.getEntryForIndex(i)
x = e.x
mBarShadowRectBuffer.left = x - barWidthHalf
mBarShadowRectBuffer.right = x + barWidthHalf
trans.rectValueToPixel(mBarShadowRectBuffer)
if (!mViewPortHandler.isInBoundsLeft(mBarShadowRectBuffer.right)) {
i++
continue
}
if (!mViewPortHandler.isInBoundsRight(mBarShadowRectBuffer.left)) {
break
}
mBarShadowRectBuffer.top = mViewPortHandler.contentTop()
mBarShadowRectBuffer.bottom = mViewPortHandler.contentBottom()
c.drawRoundRect(mBarRect, mRadius.toFloat(), mRadius.toFloat(), mShadowPaint)
i++
}
}
// initialize the buffer
val buffer = mBarBuffers[index]
buffer.setPhases(phaseX, phaseY)
buffer.setDataSet(index)
buffer.setInverted(mChart.isInverted(dataSet.axisDependency))
buffer.setBarWidth(mChart.barData.barWidth)
buffer.feed(dataSet)
trans.pointValuesToPixel(buffer.buffer)
var j = 0
while (j < buffer.size()) {
if (!mViewPortHandler.isInBoundsLeft(buffer.buffer[j + 2])) {
j += 4
continue
}
if (!mViewPortHandler.isInBoundsRight(buffer.buffer[j])) {
break
}
mRenderPaint.color = dataSet.getColor(j / 4)
val path2 = roundRect(
RectF(
buffer.buffer[j],
buffer.buffer[j + 1],
buffer.buffer[j + 2],
buffer.buffer[j + 3]
), mRadius.toFloat(), mRadius.toFloat(), true, true, true, true
)
c.drawPath(path2, mRenderPaint)
if (drawBorder) {
val path = roundRect(
RectF(
buffer.buffer[j],
buffer.buffer[j + 1],
buffer.buffer[j + 2],
buffer.buffer[j + 3]
), mRadius.toFloat(), mRadius.toFloat(), true, true, true, true
)
c.drawPath(path, mBarBorderPaint)
}
j += 4
}
}
private fun roundRect(
rect: RectF, rx: Float, ry: Float, tl: Boolean, tr: Boolean, br: Boolean, bl: Boolean
): Path {
var rx = rx
var ry = ry
val top = rect.top
val left = rect.left
val right = rect.right
val bottom = rect.bottom
val path = Path()
if (rx < 0) {
rx = 0f
}
if (ry < 0) {
ry = 0f
}
val width = right - left
val height = bottom - top
if (rx > width / 2) {
rx = width / 2
}
if (ry > height / 2) {
ry = height / 2
}
val widthMinusCorners = width - 2 * rx
val heightMinusCorners = height - 2 * ry
path.moveTo(right, top + ry)
if (tr) {
//top-right corner
path.rQuadTo(0f, -ry, -rx, -ry)
} else {
path.rLineTo(0f, -ry)
path.rLineTo(-rx, 0f)
}
path.rLineTo(-widthMinusCorners, 0f)
if (tl) {
//top-left corner
path.rQuadTo(-rx, 0f, -rx, ry)
} else {
path.rLineTo(-rx, 0f)
path.rLineTo(0f, ry)
}
path.rLineTo(0f, heightMinusCorners)
if (bl) {
//bottom-left corner
path.rQuadTo(0f, ry, rx, ry)
} else {
path.rLineTo(0f, ry)
path.rLineTo(rx, 0f)
}
path.rLineTo(widthMinusCorners, 0f)
if (br) {
//bottom-right corner
path.rQuadTo(rx, 0f, rx, -ry)
} else {
path.rLineTo(rx, 0f)
path.rLineTo(0f, -ry)
}
path.rLineTo(0f, -heightMinusCorners)
path.close() //Given close, last lineto can be removed.
return path
}
}

6
composeApp/src/androidMain/kotlin/com/whitefish/app/constants/Constants.kt

@ -0,0 +1,6 @@
package com.whitefish.app.constants
object Constants {
const val SP_NAME = "ring_app"
const val SP_KEY_BOUND_DEVICE_ADDRESS = "sp_key_bound_device_address"
}

16
composeApp/src/androidMain/kotlin/com/whitefish/app/dao/HeartRateDao.kt

@ -0,0 +1,16 @@
package com.whitefish.app.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.whitefish.app.dao.bean.HeartRateDataBean
@Dao
interface HeartRateDao{
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun putHeartRate(beans: List<HeartRateDataBean>)
@Query("select * from HeartRateDataBean order by time desc")
suspend fun getHeartRateList():List<HeartRateDataBean>
}

17
composeApp/src/androidMain/kotlin/com/whitefish/app/dao/SleepDao.kt

@ -0,0 +1,17 @@
package com.whitefish.app.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.whitefish.app.dao.bean.SleepDataBean
@Dao
interface SleepDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(sleep:SleepDataBean)
@Query("select * from SleepDataBean where date = :date order by startTime desc")
suspend fun getAllSleepData(date:Long):SleepDataBean
}

18
composeApp/src/androidMain/kotlin/com/whitefish/app/dao/WorkoutDao.kt

@ -0,0 +1,18 @@
package com.whitefish.app.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.whitefish.app.dao.bean.WorkoutDbData
@Dao
interface WorkoutDao {
@Insert
suspend fun insert(workout: WorkoutDbData)
@Query("SELECT * FROM WorkoutDbData ORDER BY startTs DESC")
suspend fun getAllWorkouts(): List<WorkoutDbData>
@Query("SELECT * FROM WorkoutDbData WHERE date = :date")
suspend fun getWorkoutsByDate(date: String): List<WorkoutDbData>
}

13
composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/HeartRateDataBean.kt

@ -0,0 +1,13 @@
package com.whitefish.app.dao.bean
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@Entity
@Parcelize
data class HeartRateDataBean (
@PrimaryKey val time:Long,
val value:Int,
) : Parcelable

21
composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/SleepDataBean.kt

@ -0,0 +1,21 @@
package com.whitefish.app.dao.bean
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.whitefish.app.view.SleepSegment
import kotlinx.parcelize.Parcelize
import java.sql.Timestamp
import java.util.Date
@Parcelize
@Entity
data class SleepDataBean(
val startTime:Long,
@PrimaryKey val date: Long,
val totalSleepTime: Long,
val sleepSegments: List<SleepSegment> = emptyList(),
val score: String = "",
val rating:Float = 0f,
) : Parcelable

43
composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/WorkoutDbData.kt

@ -0,0 +1,43 @@
package com.whitefish.app.dao.bean
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@Entity
@Parcelize
data class WorkoutDbData(
@PrimaryKey val startTs: Long, /* 锻炼开始时间戳,也是该条记录时间戳 */
var endTs: Long, /* 锻炼结束时间 */
val date: Long, /* 上面时间戳对应的日期(yyyy-MM-dd'T'HH:mm:ssXXX) */
val btMac: String, /* 该条记录对应的设备蓝牙地址 */
/**
* [WORKOUT_TYPE_WALKING]
*
* [WORKOUT_TYPE_INDOOR_RUNNING]
*
* [WORKOUT_TYPE_OUTDOOR_RUNNING]
*
* [WORKOUT_TYPE_INDOOR_CYCLING]
*
* [WORKOUT_TYPE_OUTDOOR_CYCLING]
*
* [WORKOUT_TYPE_MOUNTAIN_BIKING]
*
* [WORKOUT_TYPE_SWIMMING]
* */
val type: Int,
val target: Int,
) : Parcelable {
}
enum class WorkoutType{
WORKOUT_TYPE_WALKING,
WORKOUT_TYPE_INDOOR_RUNNING,
WORKOUT_TYPE_OUTDOOR_RUNNING,
WORKOUT_TYPE_INDOOR_CYCLING,
WORKOUT_TYPE_OUTDOOR_CYCLING,
WORKOUT_TYPE_MOUNTAIN_BIKING,
WORKOUT_TYPE_SWIMMING,
}

21
composeApp/src/androidMain/kotlin/com/whitefish/app/dao/converter/SleepSegmentConverter.kt

@ -0,0 +1,21 @@
package com.whitefish.app.dao.converter
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.whitefish.app.view.SleepSegment
class SleepSegmentConverter {
private val gson = Gson()
private val type = object : TypeToken<List<SleepSegment>>() {}.type
@TypeConverter
fun fromString(value: String): List<SleepSegment> {
return gson.fromJson(value, type)
}
@TypeConverter
fun fromList(list: List<SleepSegment>): String {
return gson.toJson(list)
}
}

280
composeApp/src/androidMain/kotlin/com/whitefish/app/data/FakeRepository.kt

@ -0,0 +1,280 @@
package com.whitefish.app.data
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineDataSet
import com.whitefish.app.bean.Evaluate
import com.whitefish.app.bean.Exercise
import com.whitefish.app.feature.home.bean.ExerciseHistory
import com.whitefish.app.feature.home.bean.RecoveryLineChart
import com.whitefish.app.feature.home.bean.Tips
import com.whitefish.app.utils.getPastDayCalendar
import lib.linktop.nexring.api.SLEEP_STATE_DEEP
import lib.linktop.nexring.api.SLEEP_STATE_LIGHT
import lib.linktop.nexring.api.SLEEP_STATE_REM
import lib.linktop.nexring.api.SLEEP_STATE_WAKE
import lib.linktop.nexring.api.SleepData
import lib.linktop.nexring.api.SleepStage
/**
* Test repository
*/
object FakeRepository {
// Mock数据构造函数
fun createMockSleepData(offset: Int): lib.linktop.nexring.api.SleepData {
// 时间戳:假设从昨晚23:30到今早7:00的睡眠
val sleepStartTime = getPastDayCalendar(offset).timeInMillis - (8 * 60 * 60 * 1000) // 8小时前
val sleepEndTime = getPastDayCalendar(offset).timeInMillis
val totalDuration = sleepEndTime - sleepStartTime
// 构造睡眠阶段数据
val mockSleepStages = arrayListOf(
// 入睡阶段 - 浅睡眠
SleepStage(-0.30f, -0.15f, SLEEP_STATE_LIGHT), // 23:30-23:45
SleepStage(-0.15f, 0.45f, SLEEP_STATE_DEEP), // 23:45-00:45
SleepStage(0.45f, 1.30f, SLEEP_STATE_LIGHT), // 00:45-01:30
SleepStage(1.30f, 2.15f, SLEEP_STATE_REM), // 01:30-02:15
SleepStage(2.15f, 3.45f, SLEEP_STATE_DEEP), // 02:15-03:45
SleepStage(3.45f, 4.30f, SLEEP_STATE_LIGHT), // 03:45-04:30
SleepStage(4.30f, 5.15f, SLEEP_STATE_REM), // 04:30-05:15
SleepStage(5.15f, 6.30f, SLEEP_STATE_LIGHT), // 05:15-06:30
SleepStage(6.30f, 6.45f, SLEEP_STATE_WAKE), // 06:30-06:45
SleepStage(6.45f, 7.00f, SLEEP_STATE_LIGHT) // 06:45-07:00
)
// 构造睡眠状态统计数据
val mockSleepStates = arrayOf(
lib.linktop.nexring.api.SleepState(
duration = 30 * 60 * 1000L,
percent = 6.25f
), // 清醒:30分钟
lib.linktop.nexring.api.SleepState(
duration = 135 * 60 * 1000L,
percent = 28.125F
), // REM:2小时15分钟
lib.linktop.nexring.api.SleepState(
duration = 165 * 60 * 1000L,
percent = 34.375F
), // 浅睡眠:2小时45分钟
lib.linktop.nexring.api.SleepState(
duration = 150 * 60 * 1000L,
percent = 31.25F
), // 深睡眠:2小时30分钟
lib.linktop.nexring.api.SleepState(
duration = 0L,
percent = 0.0F
) // 小憩:0分钟
)
return SleepData(
startTs = sleepStartTime,
endTs = sleepEndTime,
btMac = "AA:BB:CC:DD:EE:FF",
duration = totalDuration,
hr = 58.5, // 平均心率
hrv = 42.3, // 平均心率变异性
rr = 16.2, // 平均呼吸率
spo2 = 97.8, // 平均血氧饱和度
hrDip = 12.5, // 心率下降百分比
sleepStages = mockSleepStages,
sleepStates = mockSleepStates,
isNap = false,
efficiency = 90.0
)
}
// suspend fun deviceList(): List<Device> {
// return arrayListOf(Device("", "Test", "123"), Device("", "Test", "123"))
// }
// suspend fun stateList(): List<Any> {
// return arrayListOf(
// ExerciseTarget(35, "1234"),
// RecoveryScore(
// 55,
// "预计2月31日00:00后\n完全恢复",
// "啊八八八八八八Abba艾伯克兰十大事件都可能",
// Date(System.currentTimeMillis())
// ),
// HeardRate(
// HeardRate.SIZE_HALF_ROW, LineDataSet(
// arrayListOf<Entry>(
// Entry(1.0f, 2.0f),
// Entry(2.0f, 1.0f),
// Entry(3.0f, 5.0f),
// Entry(4.0f, 2.0f),
// Entry(5.0f, 2.0f),
// Entry(6.0f, 1.0f),
// Entry(7.0f, 5.0f),
// Entry(8.0f, 2.0f),
// Entry(9.0f, 2.0f),
// Entry(10.0f, 1.0f),
// Entry(11.0f, 5.0f),
// Entry(12.0f, 2.0f),
// Entry(13.0f, 2.0f),
// Entry(14.0f, 1.0f),
// Entry(15.0f, 5.0f),
// Entry(16.0f, 2.0f)
// ), ""
// ), "999次/一分钟", "7/29", "你的心率非常不正常"
// ),
//// SleepState("7/12","7小时", sleepData(),"7",7f,"5小时","2小时","0小时")
// )
// }
suspend fun exerciseStateList(): Exercise {
return Exercise(
"Alin",
"",
123,
456,
arrayListOf(
ExerciseHistory("123", "!@#", "!23"),
Evaluate(70, 85, 30, 20, 10, 5),
Tips("巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉")
)
)
}
suspend fun recoveryStateList(): List<Any> {
return arrayListOf(
// RecoveryScore(
// 55,
// "预计2/32日18:00后完全恢复",
// "你当天的身体状态似乎不太好,请注意保持适量的运动,晚上早点休息,养成良好的锻炼和睡眠规律。",
// Date(System.currentTimeMillis())
// ),
RecoveryLineChart(
-1f,
lineChartData = LineDataSet(
arrayListOf<Entry>(
Entry(1.0f, 0.0f),
Entry(2.0f, -1.0f),
Entry(3.0f, -1.0f),
Entry(4.0f, -2.0f),
Entry(5.0f, -2.0f),
Entry(6.0f, -4.0f),
Entry(7.0f, -3.0f),
), ""
)
)
)
}
suspend fun gapListData(): List<String> {
return arrayListOf(
"10",
"20",
"30",
"40",
"50",
"60"
)
}
suspend fun distanceListData(): List<String> {
return arrayListOf(
"10",
"20",
"30",
"40",
"50",
"60"
)
}
suspend fun targetList(): List<String> {
return arrayListOf(
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
"全身减脂",
)
}
suspend fun promoteList(): List<String> {
return arrayListOf(
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
"速度和爆发力",
)
}
suspend fun exerciseList(): List<String> {
return listOf(
"131",
"1",
"dfgds",
"as",
"adcdddsadasda",
"gserwrW",
"sdfgsbdgfsdggsdfg",
"FDGssFDGSB",
"e",
"341DSFGFDGAGFSDSGsfd",
"dfhgjvbfsdajfdsjhb",
"vhjcsbvjhsbvchjdsvbaskjhd",
"asdklasdn",
"asassaasd",
"6523123",
"a54fds124aasdf"
)
}
// suspend fun sleepData() = listOf(
// // 入睡前(清醒)
// SleepChartView.SleepSegment(SLEEP_STATE_AWAKE, 15),
//
// // 初次入睡(浅睡)
// SleepChartView.SleepSegment(SLEEP_STATE_LIGHT, 40),
//
//
// // REM睡眠
// SleepChartView.SleepSegment(SLEEP_STATE_REM, 25),
//
// // 短暂醒来
// SleepChartView.SleepSegment(SLEEP_STATE_AWAKE, 8),
//
// // 再次浅睡
// SleepChartView.SleepSegment(SLEEP_STATE_LIGHT, 35),
//
// // 再次深睡
// SleepChartView.SleepSegment(SLEEP_STATE_DEEP, 45),
//
// // 浅睡
// SleepChartView.SleepSegment(SLEEP_STATE_LIGHT, 20),
//
// // 再次短暂醒来
// SleepChartView.SleepSegment(SLEEP_STATE_AWAKE, 5),
//
// // 浅睡
// SleepChartView.SleepSegment(SLEEP_STATE_LIGHT, 25),
//
// // 深睡
// SleepChartView.SleepSegment(SLEEP_STATE_DEEP, 30),
//
// // 第三次REM睡眠(梦眠)
// SleepChartView.SleepSegment(SLEEP_STATE_REM, 35),
//
// // 醒来
// SleepChartView.SleepSegment(SLEEP_STATE_AWAKE, 10)
// )
}

39
composeApp/src/androidMain/kotlin/com/whitefish/app/db/AppDatabase.kt

@ -0,0 +1,39 @@
package com.whitefish.app.db
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.whitefish.app.Application
import com.whitefish.app.dao.bean.WorkoutDbData
import com.whitefish.app.dao.HeartRateDao
import com.whitefish.app.dao.SleepDao
import com.whitefish.app.dao.WorkoutDao
import com.whitefish.app.dao.bean.HeartRateDataBean
import com.whitefish.app.dao.bean.SleepDataBean
import com.whitefish.app.dao.converter.SleepSegmentConverter
@Database(entities = [WorkoutDbData::class,HeartRateDataBean::class,SleepDataBean::class], version = 1)
@TypeConverters(SleepSegmentConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun workoutDao(): WorkoutDao
abstract fun heartRateDao(): HeartRateDao
abstract fun sleepDao(): SleepDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
Application.INSTANTS!!.applicationContext,
AppDatabase::class.java,
"ring_db"
).build()
INSTANCE = instance
instance
}
}
}
}

237
composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceDataProvider.kt

@ -0,0 +1,237 @@
package com.whitefish.app.device
import android.content.Context
import com.orhanobut.logger.Logger
import com.whitefish.app.bean.SleepNap
import com.whitefish.app.bean.SleepState
import com.whitefish.app.constants.Constants
import com.whitefish.app.dao.bean.HeartRateDataBean
import com.whitefish.app.dao.bean.SleepDataBean
import com.whitefish.app.data.FakeRepository
import com.whitefish.app.db.AppDatabase
import com.whitefish.app.utils.getDayStart
import com.whitefish.app.utils.getPastDayCalendar
import com.whitefish.app.utils.getString
import com.whitefish.app.utils.todayCalendar
import com.whitefish.app.view.SleepSegment
import com.whitefish.app.view.toSegment
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import lib.linktop.nexring.api.BatteryInfo
import lib.linktop.nexring.api.DoubleData
import lib.linktop.nexring.api.IntData
import lib.linktop.nexring.api.NexRingManager
import lib.linktop.nexring.api.Statistics
import java.sql.Timestamp
import java.util.Calendar
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.math.min
class DeviceDataProvider {
companion object {
val INSTANCE by lazy {
DeviceDataProvider()
}
}
private val address = getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, "")
private val scope = CoroutineScope(Dispatchers.IO)
private val hoursOfDay = 24
private val oneHourTs = 3600000L
suspend fun getRhr() = scope.async {
var result: Int? = 0
NexRingManager.get().sleepApi().getRhr(address, todayCalendar()) {
Logger.i("Rhr is :${it}")
result = it
}
return@async result
}.await()
/**
* 24
* @param calendar
* @return 24
*/
suspend fun getHeartRate(calendar: Calendar): List<Pair<Int, Int>> = scope.async {
val dayStartTime = calendar.getDayStart()
return@async collectHourlyHeartRates(dayStartTime)
}.await()
/**
* 24
* @param dayStartTime
* @return Pair(time,heartRate)
*/
private suspend fun collectHourlyHeartRates(dayStartTime: Long): List<Pair<Int, Int>> {
val heartRates = mutableListOf<Pair<Int, Int>>()
for (hour in 0 until hoursOfDay) {
val hourData = getHourlyHeartRate(dayStartTime, hour)
hourData?.let {
heartRates.add(Pair(hour, it))
}
}
return heartRates
}
/**
*
* @param dayStartTime
* @param hour 0-23
* @return
*/
private suspend fun getHourlyHeartRate(dayStartTime: Long, hour: Int): Int? =
suspendCoroutine { continuation ->
val hourStartTime = oneHourTs.times(hour).plus(dayStartTime)
val hourEndTime = hourStartTime.plus(oneHourTs).minus(1)
NexRingManager.get().sleepApi()
.getHrList(address, hourStartTime, hourEndTime) { result ->
result?.let {
continuation.resume(it.first.avg.toInt())
} ?: let {
continuation.resume(null)
}
}
}
suspend fun getSleepData(date: Calendar): SleepDataBean? = suspendCoroutine { continuation ->
NexRingManager.get().sleepApi().getSleepDataByDate(address, date) { sleepData ->
try {
val sleepSessionList: MutableList<SleepSegment> = ArrayList()
var totalTime = 0L
var totalStartTime = 0L
if (!sleepData.isNullOrEmpty()) {
sleepData.forEach {
if (!it.isNap) {
totalTime += it.duration
it.sleepStages.forEach { stage ->
sleepSessionList.add(stage.toSegment())
}
totalStartTime = min(totalStartTime,it.startTs)
}else{
Logger.i("Nap sleep: ${it}")
}
}
val sleepDataBean = SleepDataBean(
totalStartTime,
date.timeInMillis,
totalTime,
sleepSessionList,
)
continuation.resume(sleepDataBean)
} else {
continuation.resume(null)
}
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}
}
/**
* 24
* @param dataFetcher
* @return 24keyvalue
*/
private suspend fun <T> getLast24HoursData(
dataFetcher: (startTime: Long, endTime: Long) -> T?
): HashMap<Int, T> = scope.async {
val currentTime = System.currentTimeMillis()
val result = hashMapOf<Int, T>()
for (hourOffset in 0 until hoursOfDay) {
val targetTime = currentTime - (hourOffset * oneHourTs)
val calendar = Calendar.getInstance().apply {
timeInMillis = targetTime
}
val dayStartTime = calendar.getDayStart()
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val hourStartTime = oneHourTs.times(hour).plus(dayStartTime)
val hourEndTime = hourStartTime.plus(oneHourTs).minus(1)
dataFetcher(hourStartTime, hourEndTime)?.let {
result[hourOffset] = it
}
}
return@async result
}.await()
/**
* 24
* @return 24 Pair(, )
*/
suspend fun getLast24HoursHeartRate(): HashMap<Int, Int> {
return getLast24HoursData { startTime, endTime ->
var hourlyRate: Int? = null
NexRingManager.get().sleepApi().getHrList(address, startTime, endTime) { result ->
result?.let {
hourlyRate = it.first.avg.toInt()
}
}
hourlyRate
}
}
/**
* 24Spo2
* @return 24Spo2 Pair(, Spo2)
*/
suspend fun getLast24HoursSpo2(): HashMap<Int, List<IntData>> {
return getLast24HoursData { startTime, endTime ->
var spo2Data: List<IntData>? = null
NexRingManager.get().sleepApi().getSpo2List(address, startTime, endTime) {
Logger.i("start:${startTime},end:${endTime}:${it}")
spo2Data = it
}
spo2Data
}
}
suspend fun getAllHrList(startTs:Long,endTs: Long): List<IntData> = suspendCoroutine { continuation ->
NexRingManager.get().sleepApi().getHrList(
address,
startTs,
endTs
) {
it?.let { it1 -> continuation.resume(it1.second) }?: continuation.resume(emptyList())
}
}
suspend fun getLastTemperature(): HashMap<Int, List<DoubleData>> {
return getLast24HoursData { startTime, endTime ->
var spo2Data: List<DoubleData>? = null
NexRingManager.get().sleepApi().getFingerTemperatureList(address, startTime, endTime) {
Logger.i("start:${startTime},end:${endTime}:${it}")
spo2Data = it?.second
}
spo2Data
}
}
suspend fun getHrvList(startTs: Long, endTs: Long): Pair<Statistics, List<IntData>>? =
suspendCoroutine { continuation ->
NexRingManager.get().sleepApi().getHrvList(address, startTs, endTs) {
continuation.resume(it)
}
}
suspend fun getDayCount(): Int? = suspendCoroutine { continuation ->
NexRingManager.get().sleepApi().getDayCount(address) {
continuation.resume(it)
}
}
}

202
composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceManager.kt

@ -0,0 +1,202 @@
package com.whitefish.app.device
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import com.blankj.utilcode.util.ActivityUtils.startActivity
import com.orhanobut.logger.Logger
import com.whitefish.app.Application
import com.whitefish.app.bt.BleDevice
import com.whitefish.app.bt.OnBleConnectionListener
import com.whitefish.app.bt.OnBleScanCallback
import com.whitefish.app.utils.RefreshEmit
import com.whitefish.app.utils.cmdErrorTip
import com.whitefish.app.utils.postDelay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import lib.linktop.nexring.api.BATTERY_STATE_CHARGING
import lib.linktop.nexring.api.LOAD_DATA_EMPTY
import lib.linktop.nexring.api.LOAD_DATA_STATE_COMPLETED
import lib.linktop.nexring.api.LOAD_DATA_STATE_PROCESSING
import lib.linktop.nexring.api.LOAD_DATA_STATE_START
import lib.linktop.nexring.api.NexRingManager
import lib.linktop.nexring.api.OnSleepDataLoadListener
import lib.linktop.nexring.api.SleepData
class DeviceManager(private val app: Application) : OnBleConnectionListener, OnSleepDataLoadListener {
companion object{
const val STATE_DEVICE_CHARGING = 1
const val STATE_DEVICE_DISCHARGING = 0
const val STATE_DEVICE_DISCONNECTED = -3
const val STATE_DEVICE_CONNECTING = -2
const val STATE_DEVICE_CONNECTED = -1
val INSTANCE by lazy {
DeviceManager(Application.INSTANTS!!)
}
}
private val _scanStateFlow = MutableStateFlow(RefreshEmit())
val scanStateFlow = _scanStateFlow.asStateFlow()
val scannedDeviceList = arrayListOf<BleDevice>()
private var isRegisterBattery = false
val batteryLevel = MutableLiveData(STATE_DEVICE_DISCONNECTED to 0)
private val sycProgress = MutableLiveData(0)
var isSyncingData: Boolean = false
// var homeViewModel: demo.linktop.nexring.ui.HomeViewModel? = null
// var workoutDetailViewModel: demo.linktop.nexring.ui.workout.WorkoutDetailViewModel? = null
override fun onBleState(state: Int) {
when (state) {
BluetoothProfile.STATE_DISCONNECTED -> {
isRegisterBattery = false
batteryLevel.postValue(STATE_DEVICE_DISCONNECTED to 0)
}
BluetoothProfile.STATE_CONNECTED -> {
batteryLevel.postValue(STATE_DEVICE_CONNECTED to 0)
}
}
}
override fun onBleReady() {
postDelay {
NexRingManager.get()
.deviceApi()
.getBatteryInfo {
if (it.state == BATTERY_STATE_CHARGING) {
batteryLevel.postValue(STATE_DEVICE_CHARGING to 0)
} else {
batteryLevel.postValue(STATE_DEVICE_DISCHARGING to it.level)
}
if (!isRegisterBattery) {
isRegisterBattery = true
postDelay {
NexRingManager.get()
.sleepApi()
.syncDataFromDev()
}
}
}
}
}
override fun onSyncDataFromDevice(state: Int, progress: Int) {
Logger.i(
"onSyncDataFromDevice state: $state, progress: $progress"
)
when (state) {
LOAD_DATA_EMPTY -> {
Logger.e("Empty data")
//TODO Callback when no data is received from the device.
}
LOAD_DATA_STATE_START -> {
isSyncingData = true
sycProgress.postValue(progress)
}
LOAD_DATA_STATE_PROCESSING -> sycProgress.postValue(progress)
LOAD_DATA_STATE_COMPLETED -> {
sycProgress.postValue(progress)
isSyncingData = false
//todo sync data complete
}
}
}
override fun onSyncDataError(errorCode: Int) {
app.cmdErrorTip(errorCode)
}
override fun onOutputNewSleepData(sleepData: ArrayList<SleepData>?) {
sleepData.also {
if (it.isNullOrEmpty()) {
Logger.i(
"onOutputNewSleepData NULL"
)
} else {
Logger.i(
"onOutputNewSleepData size ${it.size}"
)
it.forEachIndexed { index, data ->
Logger.i(
"onOutputNewSleepData $index sleep from ${data.startTs} to ${data.endTs}"
)
}
}
}
}
fun registerCb() {
app.bleManager.addOnBleConnectionListener(this)
NexRingManager.get().sleepApi().setOnSleepDataLoadListener(this)
}
fun unregisterCb() {
NexRingManager.get().sleepApi().setOnSleepDataLoadListener(null)
app.bleManager.removeOnBleConnectionListener(this)
}
@SuppressLint("MissingPermission")
fun scan(context: Context) {
val bluetoothAdapter =
(context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
if (bluetoothAdapter.isEnabled) {
if (!app.bleManager.isScanning) {
scannedDeviceList.clear()
app.bleManager.startScan(20 * 1000L,
object : OnBleScanCallback {
override fun onScanning(result: BleDevice) {
Logger.i("scanned device:${result}")
scannedDeviceList.add(result)
_scanStateFlow.value = RefreshEmit()
}
override fun onScanFinished() {
_scanStateFlow.value = RefreshEmit()
}
})
}
} else {
startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
}
}
fun connect(address: String) {
with(app.bleManager) {
when (bleState.value) {
BluetoothProfile.STATE_DISCONNECTED -> {
batteryLevel.postValue(STATE_DEVICE_CONNECTING to 0)
if (!connect(address)) {
startScan(
20 * 1000L,
object : OnBleScanCallback {
override fun onScanning(result: BleDevice) {
if (result.device.address == address) {
connect(result.device)
}
}
override fun onScanFinished() {
}
})
}
}
BluetoothProfile.STATE_CONNECTED -> {
onBleState(bleState.value)
onBleReady()
}
}
}
}
}

220
composeApp/src/androidMain/kotlin/com/whitefish/app/device/ECGViewModel.kt

@ -0,0 +1,220 @@
package com.whitefish.app.device
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.ecg.algo.ECGAnalysisAlgo
import com.ecg.algo.OnECGAnalysisResultListener
import com.orhanobut.logger.Logger
import com.whitefish.app.utils.loge
import lib.linktop.nexring.api.*
import lib.linktop.nexring.api.ECGResult
const val WEARING_HABIT_LEFT_FINGER = -1
const val WEARING_HABIT_RIGHT_FINGER = 1
class ECGViewModel : ViewModel() {
var sampleRate = SAMPLE_RATE_512HZ
private val totalSecs = 30
/**
* Due to the ECG electrode definition of the device structure, under normal circumstances,
* the users need to wear the ring on the right finger to get a correct ECG signal waveform.
* But we should not restrict users wearing habits because of this.
*
* It has been confirmed that even if the user wear the ring on your left finger, the ECG module
* of the ring will still work normally, but the signal waveform obtained will be reversed to that
* when worn on the right finger.
*
* Therefore, when the user wears the ring on the left finger and wants to get a correct ECG
* signal waveform, the developer should multiply the obtained ECG signal value by -1 before
* displaying it on the widget.
*
* Note: We cannot detect whether the user wears the ring on the left finger or the right finger,
* nor can we detect whether the ECG is reversed. [Wearing Habit] is just an objective setting behavior.
* For example: If the user sets the wearing habit to the left finger and wears it on the right finger
* intentionally or unintentionally during measurement, although the signal reported by the ring is correct,
* the user still sees a reverse ECG. There is nothing we can do about this situation. The APP can only
* try its best to guide users to perform ECG measurements on fingers that conform to [Wearing Habit].
* */
var wearingHabit = WEARING_HABIT_LEFT_FINGER
set(value) {
if (field != value) {
loge("wearingHabit $value")
field = value
}
}
val maxDataSize = sampleRate.times(totalSecs)
// val ecgDrawWave = ECGDrawWave().apply {
// dataPerSec = sampleRate
// paperSpeed = PaperSpeed.VAL_25MM_PER_SEC
// gain = Gain.VAL_10MM_PER_MV
// }
// val paperSpeedLd = MutableLiveData(PaperSpeed.VAL_25MM_PER_SEC)
// val gainLd = MutableLiveData(Gain.VAL_10MM_PER_MV)
val isECGMeasuring = MutableLiveData(false)
val isLeadOnLd = MutableLiveData<Boolean?>(null)
val countdownLd = MutableLiveData<Int?>(null)
var ecgResult: ECGResult? = null
set(value) {
if (field != value) {
field = value
resultLd.postValue(value)
}
}
val resultLd = MutableLiveData<ECGResult?>(ecgResult)
val ecgDataList = ArrayList<Float>()
var startTs: Long = 0L
private var skipZeroData = true
private var ecgDataIndex = 0
var goDetails = false
var ecgPgaGain = PGA_GAIN_4VV
var dataSrc = ECG_DATA_SRC_FILTERED_WITH_RESULT
var useLocalECGAlgo = false
private var countdown: Int = 0
set(value) {
if (field != value) {
field = value
countdownLd.postValue(
if (value == 0) null
else value
)
}
}
private val ecgLocalAlgo = ECGAnalysisAlgo(object : OnECGAnalysisResultListener {
override fun onOutputFilteredECGData(data: Int) {
if (isECGMeasuring.value == true && isLeadOnLd.value == true && !(skipZeroData && data == 0) && ecgDataIndex < maxDataSize) {
skipZeroData = false
val vol = data.times(wearingHabit).toECGVoltage(dataSrc, PGA_GAIN_4VV)
ecgDataList.add(vol)
// ecgDrawWave.addData(vol)
ecgDataIndex++
val newCountdown = totalSecs - ecgDataIndex / sampleRate
if (newCountdown != countdown) {
countdown = newCountdown
}
if (ecgDataIndex >= maxDataSize) {
goDetails = true
toggle()
}
}
}
override fun onOutputECGResult(realtimeHr: Int) {
if (isLeadOnLd.value == true && dataSrc == ECG_DATA_SRC_RAW) {
ecgResult = ECGResult(
activeCal = 0,
hr = realtimeHr,
avgHr = null,
arrhythmia = 0,
isLowAmplitude = false,
isSignificantNoise = false,
isUnstableSignal = false,
isNotEnoughData = false,
rmssd = 0,
sdnn = 0,
stress = null,
bmr = 0,
signalQuality = 0,
isPresent = false,
isAlive = false
)
}
}
})
private val listener = object : OnECGMeasurementListener {
override fun onReceiveECGLeadStatus(isLeadON: Boolean) {
loge("onReceiveECGLeadStatus isLeadON ? $isLeadON")
isLeadOnLd.postValue(isLeadON)
if (isLeadON) {
ecgLocalAlgo.start()
startTs = System.currentTimeMillis()
countdown = totalSecs
} else {
ecgLocalAlgo.stop()
reset()
}
}
override fun onReceivedECGData(data: Int) {
if(useLocalECGAlgo && (dataSrc == ECG_DATA_SRC_RAW || dataSrc == ECG_DATA_SRC_RAW_WITH_RESULT)) {
ecgLocalAlgo.inputData(data)
} else if (isECGMeasuring.value == true && isLeadOnLd.value == true && !(skipZeroData && data == 0) && ecgDataIndex < maxDataSize) {
skipZeroData = false
val vol = data.times(wearingHabit).toECGVoltage(dataSrc, PGA_GAIN_4VV)
ecgDataList.add(vol)
// ecgDrawWave.addData(vol)
ecgDataIndex++
val newCountdown = totalSecs - ecgDataIndex / sampleRate
if (newCountdown != countdown) {
countdown = newCountdown
}
if (ecgDataIndex >= maxDataSize) {
goDetails = true
toggle()
}
}
}
override fun onReceivedECGResult(result: ECGResult) {
if (isLeadOnLd.value == true) {
ecgResult = result
}
}
}
init {
NexRingManager.get().healthApi().setOnECGMeasurementListener(listener)
}
override fun onCleared() {
super.onCleared()
if (isECGMeasuring.value == true) {
NexRingManager.get()
.healthApi()
.endECG(null)
}
NexRingManager.get().healthApi().setOnECGMeasurementListener(null)
ecgLocalAlgo.destroy()
}
fun toggle() {
if (isECGMeasuring.value == true) {
//代表正在测量中
isECGMeasuring.postValue(false)
isLeadOnLd.postValue(null)
NexRingManager.get().healthApi().endECG(null)
ecgLocalAlgo.stop()
} else {
goDetails = false
ecgResult = null
ecgDataList.clear()
NexRingManager.get().healthApi()
.startECG(sampleRate, ecgPgaGain, dataSrc) {
if (it == 0)
isECGMeasuring.postValue(true)
}
}
}
fun reset() {
skipZeroData = true
ecgDataIndex = 0
ecgDataList.clear()
// ecgDrawWave.clear()
countdown = 0
ecgResult = null
}
}

60
composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Chart.kt

@ -0,0 +1,60 @@
package com.whitefish.app.ext
import android.graphics.Color
import com.github.mikephil.charting.charts.BarChart
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.components.XAxis
fun LineChart.clearDraw(){
setBackgroundColor(Color.TRANSPARENT)
setTouchEnabled(false)
isDragEnabled = false
setScaleEnabled(false)
//隐藏背景线
xAxis.setDrawGridLines(false)
axisLeft.setDrawGridLines(false)
axisRight.setDrawGridLines(false)
setGridBackgroundColor(Color.TRANSPARENT)
//隐藏坐标轴
xAxis.isEnabled = false
axisLeft.isEnabled = false
axisRight.isEnabled = false
//禁用图例
legend.isEnabled = false
description.isEnabled = false
}
fun BarChart.clearDraw(){
setBackgroundColor(Color.TRANSPARENT)
setTouchEnabled(false)
isDragEnabled = false
setScaleEnabled(false)
//隐藏背景线
xAxis.setDrawGridLines(false)
axisLeft.setDrawGridLines(false)
axisRight.setDrawGridLines(false)
setGridBackgroundColor(Color.TRANSPARENT)
//隐藏坐标轴
xAxis.isEnabled = false
axisLeft.isEnabled = false
axisRight.isEnabled = false
//禁用图例
legend.isEnabled = false
description.isEnabled = false
}
fun BarChart.customStyle(count:Int){
xAxis.position = XAxis.XAxisPosition.BOTTOM
xAxis.setCenterAxisLabels(true)
xAxis.setAxisMinimum(0f)
xAxis.setAxisMaximum(count.toFloat())
xAxis.setLabelCount(count)
xAxis.granularity = 0.1F
xAxis.isGranularityEnabled = true
}

68
composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Lifecycle.kt

@ -0,0 +1,68 @@
package com.whitefish.app.ext
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
inline fun <T> Flow<T>.collectWith(
lifecycleOwner: LifecycleOwner,
lifecycleState: Lifecycle.State,
crossinline action: suspend (T) -> Unit
){
lifecycleOwner.launchWith(lifecycleState){
collect{
action(it)
}
}
}
inline fun LifecycleOwner.launchWith(
lifecycleState: Lifecycle.State,
crossinline block: suspend CoroutineScope.() -> Unit
) {
lifecycle.addObserver(object : DefaultLifecycleObserver {
var job: Job? = null
override fun onCreate(owner: LifecycleOwner) {
launchBlockWhen(Lifecycle.State.CREATED)
}
override fun onStart(owner: LifecycleOwner) {
launchBlockWhen(Lifecycle.State.STARTED)
}
override fun onResume(owner: LifecycleOwner) {
launchBlockWhen(Lifecycle.State.RESUMED)
}
override fun onPause(owner: LifecycleOwner) {
cancelWhen(Lifecycle.State.RESUMED)
}
override fun onStop(owner: LifecycleOwner) {
cancelWhen(Lifecycle.State.STARTED)
}
override fun onDestroy(owner: LifecycleOwner) {
cancelWhen(Lifecycle.State.CREATED)
}
private fun launchBlockWhen(state: Lifecycle.State) {
if (lifecycleState == state) {
job = lifecycleScope.launch { block() }
}
}
private fun cancelWhen(state: Lifecycle.State) {
if (lifecycleState == state) {
job?.takeUnless { it.isCancelled }?.cancel()
job = null
}
}
})
}

8
composeApp/src/androidMain/kotlin/com/whitefish/app/ext/List.kt

@ -0,0 +1,8 @@
package com.whitefish.app.ext
import androidx.recyclerview.widget.GridLayoutManager
fun GridLayoutManager.isLeftItem(position: Int,totalColum:Int): Boolean {
val spanIndex: Int = spanSizeLookup.getSpanIndex(position, totalColum)
return spanIndex %2 == 0
}

143
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/connect/ConnectTipActivity.kt

@ -0,0 +1,143 @@
package com.whitefish.app.feature.connect
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import com.whitefish.app.BaseActivity
import com.whitefish.app.R
import com.whitefish.app.bt.OnBleConnectionListener
import com.whitefish.app.constants.Constants
import com.whitefish.app.databinding.ActivityConnectBinding
import com.whitefish.app.device.DeviceManager
import com.whitefish.app.feature.devices.DeviceActivity
import com.whitefish.app.feature.devices.DevicesViewModel
import com.whitefish.app.feature.home.HomeActivity
import com.whitefish.app.utils.goEnableLocationServicePage
import com.whitefish.app.utils.postDelay
import lib.linktop.nexring.api.NexRingManager
class ConnectTipActivity : BaseActivity<ActivityConnectBinding, DevicesViewModel>() {
companion object {
fun start(context: Context) {
val intent = Intent(context, ConnectTipActivity::class.java)
context.startActivity(intent)
}
}
private var permissionChecker =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
checkPermission()
}
override fun setLayout(): Int {
return R.layout.activity_connect
}
override fun setViewModel(): Class<DevicesViewModel> {
return DevicesViewModel::class.java
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun bind() {
checkPermission()
mBinding.btLogin.setOnClickListener {
DeviceActivity.start(this)
}
val address = com.whitefish.app.utils.getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS,"")
if (address.isNotBlank()){
HomeActivity.start(this)
finish()
}
}
@SuppressLint("MissingPermission")
private fun Context.checkPermission() {
val dinedPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
checkDeniedPermissions(
this,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
checkDeniedPermissions(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
}
if (dinedPermissions != null) {
permissionChecker.launch(dinedPermissions)
return
}
if (!locationServiceAllowed()) {
AlertDialog.Builder(this)
.setMessage(R.string.dialog_msg_turn_on_location_service)
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ ->
goEnableLocationServicePage()
}
.create().show()
return
}
val bluetoothAdapter =
(getSystemService(BLUETOOTH_SERVICE) as BluetoothManager).adapter
if (!bluetoothAdapter.isEnabled) {
startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
}
// app.accountSp.getString(SP_KEY_BOUND_DEVICE_ADDRESS, "").apply {
// if (isNullOrEmpty()) {
// findNavController().navigate(R.id.action_Home_to_BindDevice)
// } else {
// viewModel.currBtMac = this
// viewModel.loadDateData()
// else {
// startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
// }
// }
// }
}
private fun Context.locationServiceAllowed(): Boolean {
return if (Build.VERSION.SDK_INT in Build.VERSION_CODES.M..Build.VERSION_CODES.R) {
val manager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) manager.isLocationEnabled
else manager.isProviderEnabled(LocationManager.GPS_PROVIDER)
} else {
//Other versions do not need to turn on location services,
//so it can be considered that location services are turned on.
true
}
}
private fun checkDeniedPermissions(
context: Context,
vararg permissions: String,
): Array<String>? {
val dinedPermissions: MutableList<String> = ArrayList()
for (permission in permissions) {
if (ActivityCompat.checkSelfPermission(context, permission)
!= PackageManager.PERMISSION_GRANTED
) {
dinedPermissions.add(permission)
}
}
return if (dinedPermissions.isEmpty()) null else dinedPermissions.toTypedArray()
}
}

207
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DeviceActivity.kt

@ -0,0 +1,207 @@
package com.whitefish.app.feature.devices
import android.animation.ObjectAnimator
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import com.orhanobut.logger.Logger
import com.whitefish.app.Application
import com.whitefish.app.BaseActivity
import com.whitefish.app.R
import com.whitefish.app.bt.OnBleConnectionListener
import com.whitefish.app.constants.Constants
import com.whitefish.app.databinding.ActivityDevicesBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.device.DeviceManager
import com.whitefish.app.feature.userInfo.UserInfoActivity
import com.whitefish.app.utils.postDelay
import com.whitefish.app.utils.putString
import com.whitefish.app.view.ConnectErrorDialog
import lib.linktop.nexring.api.NexRingManager
class DeviceActivity : BaseActivity<ActivityDevicesBinding, DevicesViewModel>() {
companion object {
fun start(context: Context) {
val intent = Intent(context, DeviceActivity::class.java)
context.startActivity(intent)
}
}
private var isConnecting = false
private lateinit var deviceAdapter: DevicesAdapter
private var loadingAnimator: ObjectAnimator? = null
private val mOnBleConnectionListener = object : OnBleConnectionListener {
override fun onBleState(state: Int) {
/**
* when call [factoryReset], device will disconnect.
*/
if (state == BluetoothProfile.STATE_DISCONNECTED) {
if (isConnecting){
ConnectErrorDialog().show(supportFragmentManager,"error")
}
deviceAdapter.clear()
DeviceManager.INSTANCE.scan(this@DeviceActivity)
}
}
override fun onBleReady() {
postDelay {
NexRingManager.get()
.deviceApi()
.getBindState {
Logger.i("bind state:${it}")
if (it) {
AlertDialog.Builder(this@DeviceActivity)
.setCancelable(false)
.setTitle(R.string.dialog_title_restricted_mode)
.setMessage(R.string.dialog_msg_restricted_mode)
.setNegativeButton(android.R.string.cancel) { _, _ ->
}.setPositiveButton(android.R.string.ok) { _, _ ->
Logger.i("reset device")
deviceAdapter.clear()
NexRingManager.get()
.deviceApi()
.factoryReset()
switchUI(true)
postDelay {
Thread.sleep(200)
DeviceManager.INSTANCE.scan(this@DeviceActivity)
}
isConnecting = false
}.create().show()
} else {
bindDevice()
}
}
}
}
}
private fun bindDevice() {
NexRingManager.get()
.deviceApi()
.bind {
Logger.i("bind result:${it}")
if (it == 0) {
Logger.i("bind device")
putString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, deviceAdapter.data[deviceAdapter.connectingPosition].address)
UserInfoActivity.start(this@DeviceActivity)
finish()
}
}
}
override fun setLayout(): Int {
return R.layout.activity_devices
}
override fun setViewModel(): Class<DevicesViewModel> {
return DevicesViewModel::class.java
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Application.INSTANTS?.bleManager?.addOnBleConnectionListener(mOnBleConnectionListener)
}
override fun bind() {
initAdapter(mBinding.rvDevices)
initLoadingAnimation()
switchUI(true)
collectData()
DeviceManager.INSTANCE.scan(this)
}
private fun initAdapter(recyclerView: RecyclerView) {
deviceAdapter = DevicesAdapter { device ->
isConnecting = true
DeviceManager.INSTANCE.connect(device.address)
}
recyclerView.adapter = deviceAdapter
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
return
}
//22dp - 上下5dp阴影
outRect.bottom =
resources.getDimensionPixelOffset(R.dimen.device_item_bottom_offset)
}
})
}
private fun initLoadingAnimation() {
loadingAnimator = ObjectAnimator.ofFloat(mBinding.ivLoading, View.ROTATION, 0f, 360f).apply {
duration = 1000
repeatCount = ObjectAnimator.INFINITE
interpolator = LinearInterpolator()
}
}
private fun collectData() {
mViewModel.viewState.collectWith(this, Lifecycle.State.RESUMED) {
when (it) {
is ViewState.Success -> {
switchUI(it.items.isEmpty())
deviceAdapter.setData(it.items)
}
is ViewState.Empty -> {}
is ViewState.Error -> {}
is ViewState.Default -> {}
}
}
}
private fun switchUI(scanning:Boolean) {
if (!scanning) {
mBinding.tvTitle.text = getString(R.string.devices_list)
stopLoadingAnimation()
mBinding.ivLoading.visibility = View.GONE
mBinding.rvDevices.visibility = View.VISIBLE
mBinding.tip.text = getString(R.string.devices_connect_fail)
} else {
mBinding.tvTitle.text = getString(R.string.devices_scan)
mBinding.ivLoading.visibility = View.VISIBLE
startLoadingAnimation()
mBinding.rvDevices.visibility = View.GONE
mBinding.tip.text = getString(R.string.devices_can_not_find)
}
}
private fun startLoadingAnimation() {
loadingAnimator?.start()
}
private fun stopLoadingAnimation() {
loadingAnimator?.cancel()
}
override fun onDestroy() {
super.onDestroy()
stopLoadingAnimation()
Application.INSTANTS?.bleManager?.removeOnBleConnectionListener(mOnBleConnectionListener)
}
}

100
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesAdapter.kt

@ -0,0 +1,100 @@
package com.whitefish.app.feature.devices
import android.animation.ObjectAnimator
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.LinearInterpolator
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.bean.Device
import com.whitefish.app.databinding.ListItemDeviceBinding
class DevicesAdapter(
private val onItemClickListener: ListItemDeviceBinding.(Device) -> Unit
) : BaseAdapter<Device, DeviceListItemHolder>() {
private lateinit var binding: ListItemDeviceBinding
// 记录当前正在连接的项位置
var connectingPosition: Int = -1
private set
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceListItemHolder {
binding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.list_item_device,
parent,
false
)
return DeviceListItemHolder(binding) { device, position ->
// 更新连接状态并刷新
connectingPosition = position
notifyDataSetChanged()
// 调用原始的点击监听器
onItemClickListener.invoke(binding, device)
}
}
override fun onBindViewHolder(holder: DeviceListItemHolder, position: Int) {
holder.bind(data[position], position, position == connectingPosition)
}
fun clear(){
setData(emptyList())
connectingPosition = -1
notifyDataSetChanged()
}
fun resetSelected(){
connectingPosition = -1
notifyDataSetChanged()
}
}
class DeviceListItemHolder(
private val binding: ListItemDeviceBinding,
private val onItemClickListener: (Device, Int) -> Unit
) : ViewHolder(binding.root) {
private var loadingAnimator: ObjectAnimator? = null
init {
// 初始化旋转动画
loadingAnimator = ObjectAnimator.ofFloat(
binding.connectLoading,
View.ROTATION,
0f,
360f
).apply {
duration = 1000
repeatCount = ObjectAnimator.INFINITE
interpolator = LinearInterpolator()
}
}
fun bind(device: Device, position: Int, isConnecting: Boolean) {
binding.device = device
// 根据连接状态显示或隐藏连接中的视图
if (isConnecting) {
binding.connecting.visibility = View.VISIBLE
startLoadingAnimation()
} else {
binding.connecting.visibility = View.GONE
stopLoadingAnimation()
}
binding.root.setOnClickListener {
onItemClickListener.invoke(device, position)
}
}
private fun startLoadingAnimation() {
loadingAnimator?.start()
}
private fun stopLoadingAnimation() {
loadingAnimator?.cancel()
}
}

61
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesViewModel.kt

@ -0,0 +1,61 @@
package com.whitefish.app.feature.devices
import androidx.lifecycle.viewModelScope
import com.whitefish.app.BaseViewModel
import com.whitefish.app.bean.Device
import com.whitefish.app.bean.toDevice
import com.whitefish.app.device.DeviceManager
import com.whitefish.app.utils.RefreshEmit
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class DevicesViewModel:BaseViewModel() {
private val _viewState = MutableSharedFlow<ViewState>(0)
internal val viewState: SharedFlow<ViewState> = _viewState.asSharedFlow()
val refreshFlow = MutableStateFlow(RefreshEmit())
init {
collect()
}
fun refresh(){
viewModelScope.launch {
refreshFlow.emit(RefreshEmit())
}
}
private fun collect(){
viewModelScope.launch {
DeviceManager.INSTANCE.scanStateFlow.collectLatest {
val bleDevices = DeviceManager.INSTANCE.scannedDeviceList
val devices = arrayListOf<Device>()
bleDevices.forEach {
devices.add(it.toDevice())
}
if (devices.isNotEmpty()){
_viewState.emit(ViewState.Success(devices))
}else{
_viewState.emit(ViewState.Empty)
}
}
}
}
}
internal sealed class ViewState {
data class Success(val items: MutableList<Device>) :
ViewState()
data class Error(val error: Exception) : ViewState()
data object Empty : ViewState()
data object Default: ViewState()
}

206
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeActivity.kt

@ -0,0 +1,206 @@
package com.whitefish.app.feature.home
import android.animation.ObjectAnimator
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.core.graphics.toColorInt
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import com.orhanobut.logger.Logger
import com.whitefish.app.Application
import com.whitefish.app.BaseActivity
import com.whitefish.app.R
import com.whitefish.app.bt.OnBleConnectionListener
import com.whitefish.app.constants.Constants
import com.whitefish.app.databinding.ActivityHomeBinding
import com.whitefish.app.device.DeviceManager
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.home.exercise.ExerciseFragment
import com.whitefish.app.feature.home.recovery.RecoveryFragment
import com.whitefish.app.feature.home.setting.SettingFragment
import com.whitefish.app.feature.home.state.StateFragment
import lib.linktop.nexring.api.NexRingManager
class HomeActivity : BaseActivity<ActivityHomeBinding, HomeViewModel>() {
private var currentFragmentTag = StateFragment.TAG
val address =
com.whitefish.app.utils.getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, "")
private var loadingAnimator: ObjectAnimator? = null
private val mOnBleConnectionListener = object : OnBleConnectionListener {
override fun onBleState(state: Int) {
when (state) {
BluetoothProfile.STATE_DISCONNECTED -> {
Application.INSTANTS!!.bleManager.connect(address)
startLoadingAnimation()
}
BluetoothProfile.STATE_CONNECTED -> {
mBinding.loading.visibility = View.GONE
stopLoadingAnimation()
}
}
}
override fun onBleReady() {
NexRingManager.get()
.deviceApi()
.getBatteryInfo {
Logger.i("power:${it}")
}
}
}
companion object {
fun start(context: Context) {
val intent = Intent(context, HomeActivity::class.java)
context.startActivity(intent)
}
}
override fun setLayout(): Int {
return R.layout.activity_home
}
override fun setViewModel(): Class<HomeViewModel> {
return HomeViewModel::class.java
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DeviceManager.INSTANCE.registerCb()
Application.INSTANTS!!.bleManager.addOnBleConnectionListener(mOnBleConnectionListener)
initLoadingAnimation()
if (Application.INSTANTS!!.bleManager.bleState.value == BluetoothProfile.STATE_CONNECTED){
mBinding.loading.visibility= View.GONE
} else if (Application.INSTANTS!!.bleManager.bleState.value != BluetoothProfile.STATE_CONNECTING){
Application.INSTANTS!!.bleManager.connect(address)
startLoadingAnimation()
}
Application.INSTANTS!!.bleManager.oemStepComplete.collectWith(this,Lifecycle.State.CREATED){
if (it){
mViewModel.cacheData()
}
}
}
private fun initLoadingAnimation() {
loadingAnimator = ObjectAnimator.ofFloat(mBinding.icLoading, View.ROTATION, 0f, 360f).apply {
duration = 1000
repeatCount = ObjectAnimator.INFINITE
interpolator = LinearInterpolator()
}
}
private fun startLoadingAnimation() {
loadingAnimator?.start()
}
private fun stopLoadingAnimation() {
loadingAnimator?.cancel()
}
override fun bind() {
showFragment(currentFragmentTag)
mBinding.icState.backgroundTintList = ColorStateList.valueOf("#352764".toColorInt())
mBinding.icExerices.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icRecovery.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icSetting.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.llState.setOnClickListener {
showFragment(StateFragment.TAG)
mBinding.icState.backgroundTintList = ColorStateList.valueOf("#352764".toColorInt())
mBinding.icExerices.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icRecovery.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icSetting.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
}
mBinding.llExercise.setOnClickListener {
showFragment(ExerciseFragment.TAG)
mBinding.icExerices.backgroundTintList = ColorStateList.valueOf("#352764".toColorInt())
mBinding.icState.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icRecovery.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icSetting.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
}
mBinding.llRecovery.setOnClickListener {
showFragment(RecoveryFragment.TAG)
mBinding.icRecovery.backgroundTintList = ColorStateList.valueOf("#352764".toColorInt())
mBinding.icExerices.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icState.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icSetting.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
}
mBinding.llSetting.setOnClickListener {
showFragment(SettingFragment.TAG)
mBinding.icSetting.backgroundTintList = ColorStateList.valueOf("#352764".toColorInt())
mBinding.icExerices.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icRecovery.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
mBinding.icState.backgroundTintList = ColorStateList.valueOf("#636363".toColorInt())
}
// AddExerciseDialog().show(supportFragmentManager,"Dialog")
}
private fun showFragment(targetTag: String) {
switchContent(
obtainFragmentInstance(currentFragmentTag),
obtainFragmentInstance(targetTag),
targetTag
)
}
private fun switchContent(from: Fragment?, to: Fragment, tag: String) {
currentFragmentTag = tag
supportFragmentManager.beginTransaction().apply {
from?.let {
hide(from)
}
if (to.isAdded)
show(to)
else
add(R.id.fragmentContainer, to, tag)
}.commit()
}
override fun onDestroy() {
super.onDestroy()
stopLoadingAnimation()
DeviceManager.INSTANCE.unregisterCb()
Application.INSTANTS!!.bleManager.removeOnBleConnectionListener(mOnBleConnectionListener)
}
private fun obtainFragmentInstance(tag: String): Fragment {
return when (tag) {
ExerciseFragment.TAG -> supportFragmentManager.findFragmentByTag(ExerciseFragment.TAG)
?: ExerciseFragment()
RecoveryFragment.TAG -> supportFragmentManager.findFragmentByTag(RecoveryFragment.TAG)
?: RecoveryFragment()
SettingFragment.TAG -> supportFragmentManager.findFragmentByTag(SettingFragment.TAG)
?: SettingFragment()
else ->
supportFragmentManager.findFragmentByTag(StateFragment.TAG) ?: StateFragment()
}
}
}

39
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeViewModel.kt

@ -0,0 +1,39 @@
package com.whitefish.app.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.orhanobut.logger.Logger
import com.whitefish.app.constants.Constants
import com.whitefish.app.dao.bean.HeartRateDataBean
import com.whitefish.app.db.AppDatabase
import com.whitefish.app.device.DeviceDataProvider
import com.whitefish.app.utils.getString
import com.whitefish.app.utils.todayCalendar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class HomeViewModel : ViewModel() {
fun cacheData(){
viewModelScope.launch(Dispatchers.IO) {
val address = getString(
Constants.SP_KEY_BOUND_DEVICE_ADDRESS,
""
)
if (address.isNotBlank()) {
val hrList = DeviceDataProvider.INSTANCE.getAllHrList(todayCalendar().timeInMillis,System.currentTimeMillis()).map {
HeartRateDataBean(it.ts,it.value)
}
if (hrList.isNotEmpty()){
AppDatabase.getDatabase().heartRateDao().putHeartRate(hrList)
}
Logger.i("cache hr data:${hrList}")
val sleepData = DeviceDataProvider.INSTANCE.getSleepData(todayCalendar())
sleepData?.let { AppDatabase.getDatabase().sleepDao().insert(it) }
Logger.i("cache sleep data:${sleepData}")
}
}
}
}

607
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/StateAdapter.kt

@ -0,0 +1,607 @@
package com.whitefish.app.feature.home
import android.graphics.Color
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.data.BarData
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import com.orhanobut.logger.Logger
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.bean.Evaluate
import com.whitefish.app.bean.Exercise
import com.whitefish.app.bean.SleepState
import com.whitefish.app.chart.ResizedDrawable
import com.whitefish.app.chart.RightAlignedValueFormatter
import com.whitefish.app.chart.VerticalRadiusBarChartRender
import com.whitefish.app.databinding.*
import com.whitefish.app.ext.clearDraw
import com.whitefish.app.ext.customStyle
import com.whitefish.app.feature.home.bean.*
import com.whitefish.app.view.SleepChartView
import kotlin.math.absoluteValue
class StateAdapter : BaseAdapter<Any, ViewHolder>() {
enum class ViewType {
CARD_EXERCISE_TARGET,
CARD_RECOVERY_SCORE,
CARD_HEART_RATE_HALF_ROW,
CARD_BAR_CHART_SMALL,
CARD_EXERCISE_HISTORY,
CARD_TEMPERATURE_LINE_CHART_FULL_ROW,
CARD_FOOT,
CARD_TIPS,
CARD_EVALUATE,
CARD_SLEEP_STATE,
CARD_HEART_RATE_FULL_ROW,
CARD_BLOOD_OXYGEN_FULL_ROW,
CARD_HEART_HEALTH
}
companion object {
const val TAG = "StateAdapter"
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
if (viewType == ViewType.CARD_EXERCISE_TARGET.ordinal) {
return ListExerciseTargetHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_exercise_target,
parent,
false
)
)
}
if (viewType == ViewType.CARD_RECOVERY_SCORE.ordinal) {
return ListRecoveryScoreHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_recovery_score,
parent,
false
)
)
}
if (viewType == ViewType.CARD_HEART_RATE_HALF_ROW.ordinal) {
return ListHeartRateHalfRowCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_heart_rate_half_row,
parent,
false
)
)
}
if (viewType == ViewType.CARD_EXERCISE_HISTORY.ordinal) {
return ListExerciseHistoryHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_exercise_history,
parent,
false
)
)
}
if (viewType == ViewType.CARD_TEMPERATURE_LINE_CHART_FULL_ROW.ordinal) {
return ListTemperatureLineFullRowCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_temperature_line_chart_full_row,
parent,
false
)
)
}
if (viewType == ViewType.CARD_FOOT.ordinal) {
return ListFootHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_foot,
parent,
false
)
)
}
if (viewType == ViewType.CARD_TIPS.ordinal) {
return ListTipsCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_tips,
parent,
false
)
)
}
if (viewType == ViewType.CARD_EVALUATE.ordinal) {
return ListEvaluateCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_evaluate,
parent,
false
)
)
}
if (viewType == ViewType.CARD_SLEEP_STATE.ordinal) {
return ListSleepStateCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_sleep_state,
parent,
false
)
)
}
if (viewType == ViewType.CARD_HEART_RATE_FULL_ROW.ordinal) {
return ListHeartRateFullRowCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_heart_rate_full_row,
parent,
false
)
)
}
if (viewType == ViewType.CARD_BLOOD_OXYGEN_FULL_ROW.ordinal) {
return ListBloodOxygenFullRowCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_blood_oxygen_full_row,
parent,
false
)
)
}
if (viewType == ViewType.CARD_HEART_HEALTH.ordinal) {
return ListHeartHealthCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_heart_health,
parent,
false
)
)
}
return ListBarSmallCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_bar_chart_small,
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = data[position]
//todo homepage
when (getItemViewType(position)) {
ViewType.CARD_EXERCISE_TARGET.ordinal -> {
(holder as ListExerciseTargetHolder).run {
bind(item as ExerciseTarget)
}
}
ViewType.CARD_HEART_RATE_HALF_ROW.ordinal -> {
(holder as ListHeartRateHalfRowCardHolder).run {
bind(item as HeardRate)
}
}
ViewType.CARD_RECOVERY_SCORE.ordinal -> {
(holder as ListRecoveryScoreHolder).run {
bind(item as RecoveryScore)
}
}
ViewType.CARD_BAR_CHART_SMALL.ordinal -> {
(holder as ListBarSmallCardHolder).run {
bind(item as HalfRowBarChart)
}
}
ViewType.CARD_EXERCISE_HISTORY.ordinal -> {
(holder as ListExerciseHistoryHolder).run {
bind(item as ExerciseHistory)
}
}
ViewType.CARD_TEMPERATURE_LINE_CHART_FULL_ROW.ordinal -> {
(holder as ListTemperatureLineFullRowCardHolder).run {
bind(item as Temperature)
}
}
ViewType.CARD_TIPS.ordinal -> {
(holder as ListTipsCardHolder).run {
bind(item as Tips)
}
}
ViewType.CARD_FOOT.ordinal -> {
(holder as ListFootHolder)
}
ViewType.CARD_SLEEP_STATE.ordinal -> {
(holder as ListSleepStateCardHolder).run {
bind(item as SleepState)
}
}
ViewType.CARD_EVALUATE.ordinal -> {
(holder as ListEvaluateCardHolder).run {
bind(item as Evaluate)
}
}
ViewType.CARD_HEART_RATE_FULL_ROW.ordinal -> {
(holder as ListHeartRateFullRowCardHolder).run {
bind(item as HeardRate)
}
}
ViewType.CARD_BLOOD_OXYGEN_FULL_ROW.ordinal -> {
(holder as ListBloodOxygenFullRowCardHolder).run {
bind(item as BloodOxygen)
}
}
ViewType.CARD_HEART_HEALTH.ordinal -> {
(holder as ListHeartHealthCardHolder).run {
bind(item as HeartHealth)
}
}
}
}
override fun getItemViewType(position: Int): Int {
return when (val item = data[position]) {
is ExerciseTarget -> {
ViewType.CARD_EXERCISE_TARGET.ordinal
}
is HeardRate -> {
if (item.cardSize == HeardRate.SIZE_HALF_ROW) {
ViewType.CARD_HEART_RATE_HALF_ROW.ordinal
} else {
ViewType.CARD_HEART_RATE_FULL_ROW.ordinal
}
}
is HalfRowBarChart -> {
ViewType.CARD_BAR_CHART_SMALL.ordinal
}
is RecoveryScore -> {
ViewType.CARD_RECOVERY_SCORE.ordinal
}
is ExerciseHistory -> {
ViewType.CARD_EXERCISE_HISTORY.ordinal
}
is Temperature -> {
ViewType.CARD_TEMPERATURE_LINE_CHART_FULL_ROW.ordinal
}
is Tips -> {
ViewType.CARD_TIPS.ordinal
}
is Evaluate -> {
ViewType.CARD_EVALUATE.ordinal
}
is BloodOxygen -> {
ViewType.CARD_BLOOD_OXYGEN_FULL_ROW.ordinal
}
is SleepState -> {
ViewType.CARD_SLEEP_STATE.ordinal
}
is HeartHealth -> {
ViewType.CARD_HEART_HEALTH.ordinal
}
else -> {
ViewType.CARD_FOOT.ordinal
}
}
}
}
class ListExerciseTargetHolder(val binding: CardExerciseTargetBinding) : ViewHolder(binding.root) {
fun bind(data: ExerciseTarget) {
binding.cpvProgress.setProgress(data.progress)
binding.score = data.score
}
}
class ListRecoveryScoreHolder(val binding: CardRecoveryScoreBinding) : ViewHolder(binding.root) {
fun bind(score: RecoveryScore) {
binding.score = score
binding.tvScore.text = score.score.toString()//databinding 不能自动转String,傻逼
}
}
class ListHeartRateHalfRowCardHolder(val binding: CardHeartRateHalfRowBinding) :
ViewHolder(binding.root) {
fun bind(data: HeardRate) {
binding.date.text = data.date
binding.tvHeartRate.text = data.rate + "次/分钟"
//设置画布
binding.chart.clearDraw()
//设置统计图
data.lineData.apply {
setDrawFilled(true)
fillDrawable =
ContextCompat.getDrawable(binding.root.context, R.drawable.bg_line_gradient)
setColor(Color.RED)
setDrawCircles(false)
setDrawValues(false)
}
binding.chart.data = LineData(data.lineData)
Logger.i("heard rate:${data.lineData}")
binding.chart.invalidate()
}
}
class ListBarSmallCardHolder(val binding: CardBarChartSmallBinding) : ViewHolder(binding.root) {
fun bind(barData: HalfRowBarChart) {
binding.data = barData
barData.data.isHighlightEnabled = false
val data = BarData(barData.data)
data.setDrawValues(false)
val barWidth = 0.6f // 柱体宽度,这个值越小,柱体越窄
data.barWidth = barWidth
binding.chart.customStyle(barData.data.entryCount)
binding.chart.renderer =
VerticalRadiusBarChartRender(
binding.chart,
binding.chart.animator,
binding.chart.viewPortHandler
).apply {
setRadius(15)
}
binding.chart.clearDraw()
binding.chart.axisLeft.setAxisMinimum(0f)
binding.chart.setVisibleXRange(0f, barData.data.entryCount.toFloat() + 1)
binding.chart.setData(data)
binding.chart.invalidate()
}
}
class ListExerciseHistoryHolder(val binding: CardExerciseHistoryBinding) :
ViewHolder(binding.root) {
fun bind(history: ExerciseHistory) {
//todo need change
binding.totalTime.text = history.time
binding.totalDistance.text = history.alreadyComplete
binding.totalSpeed.text = history.frequency
}
}
class ListTemperatureLineFullRowCardHolder(val binding: CardTemperatureLineChartFullRowBinding) :
ViewHolder(binding.root) {
fun bind(temperature: Temperature) {
val dataSet = temperature.dataSet
val dataList = dataSet.values
if (!dataList.isNullOrEmpty()) {
val yAxis = binding.chart.axisLeft
val max = dataList.maxOfOrNull {
it.y
}
val min = dataList.minOfOrNull {
it.y
}
yAxis.axisMinimum = min!! - 2
yAxis.axisMaximum = max!! + 2
dataSet.setDrawCircles(false)
dataSet.axisDependency = YAxis.AxisDependency.LEFT
dataSet.mode = LineDataSet.Mode.CUBIC_BEZIER
dataSet.color = Color.GREEN
dataSet.setDrawValues(false)
val lineData = LineData(dataSet)
binding.chart.clearDraw()
binding.chart.setData(lineData)
binding.chart.invalidate()
}
}
}
class ListTipsCardHolder(val binding: CardTipsBinding) : ViewHolder(binding.root) {
fun bind(tips: Tips) {
binding.tips = tips
}
}
class ListEvaluateCardHolder(val binding: CardEvaluateBinding) : ViewHolder(binding.root) {
fun bind(evaluate: Evaluate) {
//todo 根据数值改变进度和颜色
binding.cpvProgress.setProgress(evaluate.progress)
binding.tvScore.text = evaluate.score.toString()
binding.lpvBenefit.apply {
setProgressColor(Color.RED)
setProgress(evaluate.benefit)
}
binding.lpvEfficiency.apply {
setProgressColor(Color.RED)
setProgress(evaluate.efficiency)
}
binding.lpvExercise.apply {
setProgressColor(Color.RED)
setProgress(evaluate.exercise)
}
binding.lpvTime.apply {
setProgressColor(Color.RED)
setProgress(evaluate.time)
}
}
}
class ListHeartRateFullRowCardHolder(val binding: CardHeartRateFullRowBinding) :
ViewHolder(binding.root) {
fun bind(data: HeardRate) {
binding.data = data
//设置画布
binding.chart.clearDraw()
//设置统计图
data.lineData.apply {
setDrawFilled(true)
fillDrawable =
ContextCompat.getDrawable(binding.root.context, R.drawable.bg_line_gradient)
setColor(binding.root.context.getColor(R.color.heart_rate_line))
setDrawCircles(false)
setDrawValues(false)
}
Log.d("StateAdapter", "${data.lineData}")
binding.chart.data = LineData(data.lineData)
binding.chart.invalidate()
}
}
class ListSleepStateCardHolder(val binding: CardSleepStateBinding):ViewHolder(binding.root) {
fun bind(sleep: SleepState) {
binding.date.text = sleep.date
binding.chart.setSleepData(sleep.sleepChartData)
}
}
class ListBloodOxygenFullRowCardHolder(val binding: CardBloodOxygenFullRowBinding) :
ViewHolder(binding.root) {
fun bind(state: BloodOxygen) {
binding.data = state
state.barDataSet.color = binding.root.context.getColor(R.color.blood_oxygen_bar)
state.barDataSet.isHighlightEnabled = false
val data = BarData(state.barDataSet)
data.setDrawValues(false)
data.barWidth = 0.6f
binding.chart.customStyle(state.barDataSet.entryCount)
binding.chart.renderer =
VerticalRadiusBarChartRender(
binding.chart,
binding.chart.animator,
binding.chart.viewPortHandler
).apply {
setRadius(15)
}
binding.chart.clearDraw()
binding.chart.axisLeft.setAxisMinimum(0f)
binding.chart.setVisibleXRange(0f, state.barDataSet.entryCount.toFloat() + 1)
binding.chart.setData(data)
binding.chart.invalidate()
}
}
class ListHeartHealthCardHolder(val binding: CardHeartHealthBinding) : ViewHolder(binding.root) {
fun bind(data: HeartHealth) {
binding.data = data
val dataSet = data.dataSet
val dataList = dataSet.values
if (!dataList.isNullOrEmpty()) {
val yAxis = binding.chart.axisLeft
val xAxis = binding.chart.xAxis
val max = dataList.maxOfOrNull {
it.y.absoluteValue
}
yAxis.axisMinimum = 0 - 2 - max!!
yAxis.axisMaximum = max + 2
dataSet.setDrawCircles(false)
dataSet.axisDependency = YAxis.AxisDependency.LEFT
dataSet.mode = LineDataSet.Mode.CUBIC_BEZIER
dataSet.setDrawValues(false)
//设置发光点
val lastPoint = dataSet.values.last()
lastPoint.icon = ResizedDrawable(binding.root.context, R.drawable.bg_hold, 100, 100)
val lastPointSet = LineDataSet(arrayListOf(lastPoint), "")
lastPointSet.setDrawIcons(true)
lastPointSet.setDrawValues(false)
//设置文本点位
val textPoint = lastPoint.copy()
textPoint.y -= 1f
textPoint.x += 1f
val textValuePointSet = LineDataSet(arrayListOf(textPoint), "")
textValuePointSet.setDrawIcons(false)
textValuePointSet.setDrawCircles(false)
textValuePointSet.valueFormatter = RightAlignedValueFormatter()
textValuePointSet.valueTextColor = Color.CYAN
textValuePointSet.valueTextSize = 15f
val lineData = LineData(dataSet, lastPointSet, textValuePointSet)
binding.chart.clearDraw()
xAxis.setAxisMaximum(dataList.size + 2f)
binding.chart.setDrawMarkers(true)
binding.chart.setData(lineData)
binding.chart.invalidate()
}
}
}
class ListFootHolder(binding: CardFootBinding) : ViewHolder(binding.root)

5
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/BloodOxygen.kt

@ -0,0 +1,5 @@
package com.whitefish.app.feature.home.bean
import com.github.mikephil.charting.data.BarDataSet
data class BloodOxygen(val avg:String,val barDataSet: BarDataSet)

7
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseHistory.kt

@ -0,0 +1,7 @@
package com.whitefish.app.feature.home.bean
data class ExerciseHistory(
val alreadyComplete:String,
val time:String,
val frequency:String
)

3
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseTarget.kt

@ -0,0 +1,3 @@
package com.whitefish.app.feature.home.bean
data class ExerciseTarget(val progress:Int,val score:String)

20
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HalfRowBarChart.kt

@ -0,0 +1,20 @@
package com.whitefish.app.feature.home.bean
import com.github.mikephil.charting.data.BarDataSet
import com.github.mikephil.charting.data.BarEntry
data class HalfRowBarChart(val title:String,val date:String, val detail:String,val suffix:String, val data:BarDataSet){
companion object{
/**
* @param valueColorMap
*/
fun build(title:String,date:String,detail:String,suffix:String,data:BarDataSet,valueColorMap:(BarEntry) -> Int):HalfRowBarChart{
val colors = ArrayList<Int>()
data.values.forEach{ barEntry ->
colors.add(valueColorMap.invoke(barEntry))
}
data.colors = colors
return HalfRowBarChart(title,date,detail,suffix, data)
}
}
}

10
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeardRate.kt

@ -0,0 +1,10 @@
package com.whitefish.app.feature.home.bean
import com.github.mikephil.charting.data.LineDataSet
data class HeardRate(val cardSize:Int, val lineData : LineDataSet, val rate:String, val date:String,val description:String){
companion object{
const val SIZE_HALF_ROW = 1
const val SIZE_FULL_ROW = 2
}
}

9
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeartHealth.kt

@ -0,0 +1,9 @@
package com.whitefish.app.feature.home.bean
import com.github.mikephil.charting.data.LineDataSet
data class HeartHealth(
val state:String,
val description:String,
val dataSet: LineDataSet
)

8
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryLineChart.kt

@ -0,0 +1,8 @@
package com.whitefish.app.feature.home.bean
import com.github.mikephil.charting.data.LineDataSet
data class RecoveryLineChart(
val targetScore:Float,
val lineChartData: LineDataSet
)

11
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryScore.kt

@ -0,0 +1,11 @@
package com.whitefish.app.feature.home.bean
import java.util.Calendar
import java.util.Date
data class RecoveryScore(
val score:Int,
val recoveryTime:String,
val tips:String,
val date: Calendar,
)

3
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Row.kt

@ -0,0 +1,3 @@
package com.whitefish.app.feature.home.bean
data class Row(val data:Pair<Any?,Any?>)

16
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Setting.kt

@ -0,0 +1,16 @@
package com.whitefish.app.feature.home.bean
enum class SettingType{
FULL_ROW_BLOCK,
HALF_ROW_BLOCK,
TITLE,
FULL_ROW_LINE
}
data class Setting(
val title: String,
val subTitle:String? = null,
val icon: Int? = null,
val type:SettingType
)

9
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Temperature.kt

@ -0,0 +1,9 @@
package com.whitefish.app.feature.home.bean
import com.github.mikephil.charting.data.LineDataSet
data class Temperature(
val date:String,
val temperature:String,
val dataSet:LineDataSet
)

5
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Tips.kt

@ -0,0 +1,5 @@
package com.whitefish.app.feature.home.bean
class Tips(
val content: String
)

83
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/AddExerciseDialog.kt

@ -0,0 +1,83 @@
package com.whitefish.app.feature.home.exercise
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import com.whitefish.app.Application
import com.whitefish.app.R
import com.whitefish.app.dao.bean.WorkoutType
import com.whitefish.app.databinding.DialogAddExerciseBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.running.RunningActivity
class AddExerciseDialog:Fragment() {
companion object{
const val TAG = "AddExerciseDialog"
}
private lateinit var _binding:DialogAddExerciseBinding
private val context by lazy {
parentFragment as ExerciseDialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DataBindingUtil.inflate(inflater, R.layout.dialog_add_exercise,container,false)
return _binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bind()
}
private fun bind(){
_binding.clToSetTarget.setOnClickListener {
context.showFragment(SetTargetDialog.TAG)
}
_binding.ivGo.setOnClickListener {
if (!Application.INSTANTS!!.bleManager.oemStepComplete.value){
Toast.makeText(Application.INSTANTS!!,"OEM检查中", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
context.hide()
val type = if (_binding.tab.selectedTabPosition == 0){
WorkoutType.WORKOUT_TYPE_OUTDOOR_RUNNING
}else{
WorkoutType.WORKOUT_TYPE_INDOOR_RUNNING
}
RunningActivity.start(requireContext(),10,type,context.viewModel.selectType.value)
}
context.viewModel.selectType.collectWith(viewLifecycleOwner,Lifecycle.State.CREATED){
when(it){
DialogExerciseViewModel.TARGET_TYPE_FREE -> {
_binding.tvTarget.text = "无目标"
}
DialogExerciseViewModel.TARGET_TYPE_HEAT -> {
_binding.tvTarget.text = "热量"
}
DialogExerciseViewModel.TARGET_TYPE_TIME -> {
_binding.tvTarget.text = "时间"
}
DialogExerciseViewModel.TARGET_TYPE_DISTANCE -> {
_binding.tvTarget.text = "距离"
}
}
}
}
}

54
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/DialogExerciseViewModel.kt

@ -0,0 +1,54 @@
package com.whitefish.app.feature.home.exercise
import androidx.lifecycle.viewModelScope
import com.whitefish.app.BaseViewModel
import com.whitefish.app.data.FakeRepository
import com.whitefish.app.utils.RefreshEmit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class DialogExerciseViewModel:BaseViewModel() {
companion object{
const val TYPE_GAP = 1
const val TYPE_DISTANCE = 2
const val TARGET_TYPE_DISTANCE = 0
const val TARGET_TYPE_TIME = 1
const val TARGET_TYPE_HEAT = 2
const val TARGET_TYPE_FREE = 3
}
val listFlow = MutableStateFlow(emptyList<String>())
private val refreshFlow = MutableStateFlow(RefreshEmit())
val selectType = MutableStateFlow(TARGET_TYPE_FREE)
init {
collect()
}
fun refresh(type:Int){
viewModelScope.launch {
refreshFlow.emit(RefreshEmit(type))
}
}
private fun collect(){
viewModelScope.launch {
refreshFlow.collectLatest {
when(it.code){
TYPE_DISTANCE -> {
listFlow.value = FakeRepository.distanceListData()
}
TYPE_GAP -> {
listFlow.value = FakeRepository.gapListData()
}
}
}
}
}
}

43
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseAdapter.kt

@ -0,0 +1,43 @@
package com.whitefish.app.feature.home.exercise
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.databinding.ListItemExerciseBinding
class ExerciseAdapter:BaseAdapter<String, ExerciseHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExerciseHolder {
return ExerciseHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),R.layout.list_item_exercise,parent,false
)
)
}
override fun onBindViewHolder(holder: ExerciseHolder, position: Int) {
holder.bind(data[position])
}
}
class ExerciseHolder(private val binding:ListItemExerciseBinding):ViewHolder(binding.root){
fun bind(name:String){
binding.name = name
when(binding.name){
"跑步" -> {
Glide.with(binding.root).load(R.drawable.running).into(binding.image)
}
"骑行" -> {
Glide.with(binding.root).load(R.drawable.riding).into(binding.image)
}
"游泳" -> {
Glide.with(binding.root).load(R.drawable.swimming).into(binding.image)
}
}
}
}

98
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseDialog.kt

@ -0,0 +1,98 @@
package com.whitefish.app.feature.home.exercise
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.whitefish.app.R
import com.whitefish.app.databinding.DialogExerciseBinding
class ExerciseDialog : DialogFragment() {
private lateinit var _binding: DialogExerciseBinding
private var currentFragmentTag = ""
val viewModel by viewModels<DialogExerciseViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DataBindingUtil.inflate(inflater, R.layout.dialog_exercise, container, false)
return _binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setWindow()
showFragment(AddExerciseDialog.TAG)
_binding.container.setOnClickListener {
dismiss()
}
}
fun showFragment(targetTag: String) {
switchContent(
obtainFragmentInstance(currentFragmentTag),
obtainFragmentInstance(targetTag),
targetTag
)
}
private fun obtainFragmentInstance(tag: String): Fragment {
return if (tag == AddExerciseDialog.TAG) {
childFragmentManager.findFragmentByTag(AddExerciseDialog.TAG)
?: AddExerciseDialog()
}else{
childFragmentManager.findFragmentByTag(SetTargetDialog.TAG)?: SetTargetDialog()
}
}
private fun switchContent(from: Fragment?, to: Fragment, tag: String) {
currentFragmentTag = tag
childFragmentManager.beginTransaction().apply {
from?.let {
hide(from)
}
if (to.isAdded)
show(to)
else
add(R.id.dialogFragmentContainer, to, tag)
}.commit()
}
private fun setWindow() {
val window = dialog!!.window
window?.decorView?.setPadding(0, 0, 0, 0)
val layoutParams = window?.attributes
layoutParams?.width = WindowManager.LayoutParams.MATCH_PARENT
layoutParams?.height = WindowManager.LayoutParams.MATCH_PARENT
window?.attributes = layoutParams
window?.decorView?.setBackgroundColor(Color.TRANSPARENT)
window?.setGravity(Gravity.CENTER)
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
// 添加这一行来禁用背景调光效果
window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
}
fun hide(){
this.dismiss()
}
}

91
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseFragment.kt

@ -0,0 +1,91 @@
package com.whitefish.app.feature.home.exercise
import android.graphics.Rect
import android.util.Log
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentExerciseBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.home.StateAdapter
class ExerciseFragment:BaseFragment<FragmentExerciseBinding, ExerciseViewModel>() {
companion object{
const val TAG = "ExerciseFragment"
}
val exerciseStateAdapter = StateAdapter()
override fun bind() {
mBinding.vm = mViewModel
initExerciseAdapter()
initStateAdapter()
collect()
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
if (!hidden){
mViewModel.refresh()
}
}
private fun initExerciseAdapter(){
val exerciseAdapter = ExerciseAdapter()
mBinding.rvExercise.adapter = exerciseAdapter
mBinding.rvExercise.layoutManager = LinearLayoutManager(requireContext(),LinearLayoutManager.HORIZONTAL,false)
mBinding.rvExercise.addItemDecoration(object : ItemDecoration(){
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.right = resources.getDimensionPixelOffset(R.dimen.exercise_card_bottom_offset)
}
})
exerciseAdapter.setData(mViewModel.exerciseList)
mBinding.clAddExerciseHistory.setOnClickListener {
ExerciseDialog().show(requireActivity().supportFragmentManager, "Dialog")
}
}
private fun initStateAdapter(){
mBinding.rvState.adapter = exerciseStateAdapter
mBinding.rvState.layoutManager = LinearLayoutManager(requireContext())
mBinding.rvState.addItemDecoration(object : ItemDecoration(){
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.bottom = resources.getDimensionPixelOffset(R.dimen.exercise_state_card_bottom_offset)
}
})
}
private fun collect(){
mViewModel.exerciseStateFlow.collectWith(viewLifecycleOwner,Lifecycle.State.RESUMED){
Log.d(TAG,"data: ${it}")
exerciseStateAdapter.setData(it.exerciseState)
}
}
override fun setLayout(): Int {
return R.layout.fragment_exercise
}
override fun setViewModel(): ExerciseViewModel {
return ViewModelProvider(this)[ExerciseViewModel::class.java]
}
}

112
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseViewModel.kt

@ -0,0 +1,112 @@
package com.whitefish.app.feature.home.exercise
import androidx.lifecycle.viewModelScope
import com.orhanobut.logger.Logger
import com.whitefish.app.BaseViewModel
import com.whitefish.app.bean.Evaluate
import com.whitefish.app.bean.Exercise
import com.whitefish.app.constants.Constants
import com.whitefish.app.data.FakeRepository
import com.whitefish.app.db.AppDatabase
import com.whitefish.app.feature.home.bean.ExerciseHistory
import com.whitefish.app.feature.home.bean.Tips
import com.whitefish.app.utils.RefreshEmit
import com.whitefish.app.utils.getString
import com.whitefish.app.utils.toTimeString
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import lib.linktop.nexring.api.NexRingManager
import lib.linktop.nexring.api.Statistics
import lib.linktop.nexring.api.WorkoutData
import java.text.NumberFormat
class ExerciseViewModel : BaseViewModel() {
val exerciseStateFlow = MutableStateFlow(Exercise("", "", 0, 0, emptyList()))
val exerciseList = arrayListOf("跑步", "骑行", "游泳")
private val refreshFlow = MutableStateFlow(RefreshEmit())
val dao = AppDatabase.getDatabase().workoutDao()
init {
collect()
}
fun refresh() {
refreshFlow.value = RefreshEmit()
}
private fun collect() {
viewModelScope.launch {
refreshFlow.collectLatest {
/**
* 1 StepLength (m) = 0.45 × Height (cm) ÷ 100
* @note No height data was collected, use 170
*
* 2 Distance (m) = StepLength × Steps
* 3 Calories (Cal) = Distance × WeightCoefficients[Intensity]
*/
val workout = getLastWorkout()
var totalTime = workout?.second?.run {
if (workout.second.size > 1) {
last().ts.minus(first().ts)
} else {
last().ts
}
}
val totalSteps = workout?.second?.run {
if (workout.second.size > 1) {
last().steps.minus(first().steps)
} else {
last().steps
}
}
val stepLength = 0.45 * 170 / 100
val totalDistance = totalSteps?.times(stepLength)
val lastDbWorkout = dao.getAllWorkouts().firstOrNull()
val distanceForKm = lastDbWorkout?.let {
val totalMs = (it.endTs - it.startTs)
val totalTime = totalMs.div(100).div(60)
totalDistance?.div(totalTime)
?.div(1000)
}?:0
val uiList = arrayListOf(
ExerciseHistory(
"$totalDistance",
(totalTime?.div(1000))?.toInt()?.toTimeString().toString(),
"${NumberFormat.getNumberInstance().apply {
maximumFractionDigits = 2
isGroupingUsed = false
}.format(distanceForKm)}/"//todo get length for 1000m
),
Evaluate(0, 0, 0, 0, 0, 0),
Tips("对于初跑者,\u200C建议采用MAF180训练法或储备心率百分比的E区、\u200C最大心率百分比的Z3区进行慢跑或其他有氧锻炼。\u200C这种低强度的运动有助于减重或锻炼,\u200C同时能较好地保持较长时间,\u200C减重或锻炼效果较好。\u200C提高步频可以预防受伤并提升跑步水平。\u200C高步频可以减少关节所承受的压力,\u200C增加落地的次数,\u200C在跑的过程中更容易调整并维持稳定的跑步姿态,\u200C减少不必要的体能消耗,\u200C增加跑步经济性。")
)
val view = Exercise("Alin", "准备好开始了吗?", 0, 0, uiList)
exerciseStateFlow.value = view
}
}
}
suspend fun getLastWorkout(): Pair<Statistics, List<WorkoutData>>? {
val lastWorkout = dao.getAllWorkouts().firstOrNull()
Logger.i("last work out is:${lastWorkout}")
var result: Pair<Statistics, List<WorkoutData>>? = null
lastWorkout?.let {
NexRingManager.get().sleepApi().getWorkoutData(
getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, ""), lastWorkout.startTs,
lastWorkout.endTs
) {
result = it
}
}
return result
}
// fun loadLastExercise
}

64
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SelectDialog.kt

@ -0,0 +1,64 @@
package com.whitefish.app.feature.home.exercise
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.whitefish.app.R
import com.whitefish.app.databinding.DialogSelectBinding
class SelectDialog(val title:String,val data:List<String>): DialogFragment() {
private lateinit var _binding: DialogSelectBinding
private val viewModel: DialogExerciseViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DataBindingUtil.inflate(inflater, R.layout.dialog_select,container,false)
return _binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setWindow()
bind()
}
private fun bind(){
_binding.setValueTitle.text = title
_binding.rvList.layoutManager = LinearLayoutManager(requireContext())
_binding.rvList.setData(data,data.size / 2)
_binding.clSetValueDone.setOnClickListener {
this.dismiss()
}
_binding.background.setOnClickListener {
this.dismiss()
}
}
private fun setWindow() {
val window = dialog!!.window
window?.decorView?.setPadding(0, 0, 0, 0)
val layoutParams = window?.attributes
layoutParams?.width = WindowManager.LayoutParams.MATCH_PARENT
layoutParams?.height = WindowManager.LayoutParams.MATCH_PARENT
window?.attributes = layoutParams
window?.decorView?.setBackgroundColor(Color.TRANSPARENT)
window?.setGravity(Gravity.BOTTOM)
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
}

106
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SetTargetDialog.kt

@ -0,0 +1,106 @@
package com.whitefish.app.feature.home.exercise
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import com.whitefish.app.R
import com.whitefish.app.databinding.DialogSetTargetBinding
import com.whitefish.app.ext.collectWith
class SetTargetDialog:Fragment() {
companion object{
const val TAG = "SetTargetDialog"
}
private lateinit var _binding: DialogSetTargetBinding
private val context by lazy {
parentFragment as ExerciseDialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DataBindingUtil.inflate(inflater, R.layout.dialog_set_target,container,false)
return _binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bind()
}
private fun bind() {
_binding.llSelectItemDistance.setOnClickListener {
context.viewModel.selectType.value = DialogExerciseViewModel.TARGET_TYPE_DISTANCE
}
_binding.llSelectItemTime.setOnClickListener {
context.viewModel.selectType.value = DialogExerciseViewModel.TARGET_TYPE_TIME
}
_binding.llSelectItemHeat.setOnClickListener {
context.viewModel.selectType.value = DialogExerciseViewModel.TARGET_TYPE_HEAT
}
_binding.llSelectItemFree.setOnClickListener {
context.viewModel.selectType.value = DialogExerciseViewModel.TARGET_TYPE_FREE
}
_binding.clTargetSetDone.setOnClickListener {
context.showFragment(AddExerciseDialog.TAG)
}
context.viewModel.selectType.collectWith(viewLifecycleOwner, Lifecycle.State.CREATED){
when(it){
DialogExerciseViewModel.TARGET_TYPE_FREE -> {
_binding.tvSetTarget.text = "无目标"
}
DialogExerciseViewModel.TARGET_TYPE_HEAT -> {
_binding.tvSetTarget.text = "热量"
}
DialogExerciseViewModel.TARGET_TYPE_TIME -> {
_binding.tvSetTarget.text = "时间"
}
DialogExerciseViewModel.TARGET_TYPE_DISTANCE -> {
_binding.tvSetTarget.text = "距离"
}
}
}
_binding.tvHeartRateNotify.setOnClickListener {
SelectDialog("间隔提醒",
listOf(
"10分钟",
"20分钟",
"30分钟",
"40分钟",
"50分钟",
"60分钟",
)
).show(requireActivity().supportFragmentManager,TAG)
}
_binding.tvGapNotify.setOnClickListener {
SelectDialog("距离",
listOf(
"10公里",
"20公里",
"30公里",
"40公里",
"50公里",
"60公里",
)
).show(requireActivity().supportFragmentManager,TAG)
}
}
}

36
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/DateAdapter.kt

@ -0,0 +1,36 @@
package com.whitefish.app.feature.home.recovery
import android.database.DatabaseUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.databinding.ListItemRecoveryDateBinding
class DateAdapter:BaseAdapter<String,DateAdapter.DateHolder>() {
class DateHolder(val binding:ListItemRecoveryDateBinding):ViewHolder(binding.root){
fun bind(data:String){
binding.data = data
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DateHolder {
return DateHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.list_item_recovery_date,
parent,
false
)
)
}
override fun onBindViewHolder(holder: DateHolder, position: Int) {
holder.bind(data[position])
}
}

353
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryAdapter.kt

@ -0,0 +1,353 @@
package com.whitefish.app.feature.home.recovery
import android.graphics.Color
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.github.mikephil.charting.components.LimitLine
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.data.BarData
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.bean.UIRecoveryState
import com.whitefish.app.bean.UIRecoveryStateItem
import com.whitefish.app.chart.DateValueFormatter
import com.whitefish.app.chart.GradientLineChartRenderer
import com.whitefish.app.chart.VerticalRadiusBarChartRender
import com.whitefish.app.databinding.CardRecoveryLineChartBinding
import com.whitefish.app.databinding.CardRecoveryStateListBinding
import com.whitefish.app.databinding.CardRecoveryTransprantBinding
import com.whitefish.app.databinding.ItemRecoveryStateBinding
import com.whitefish.app.ext.clearDraw
import com.whitefish.app.ext.customStyle
import com.whitefish.app.feature.home.bean.RecoveryLineChart
import com.whitefish.app.feature.home.bean.RecoveryScore
import com.whitefish.app.feature.recovery.HrvAssessmentActivity
import com.whitefish.app.feature.sleep.SleepDetailActivity
import com.whitefish.app.utils.formatTime
import com.whitefish.app.utils.toSpannableChineseTime
import com.whitefish.app.utils.toSpannableTime
import java.util.Calendar
class RecoveryAdapter : BaseAdapter<Any, ViewHolder>() {
enum class ViewType {
CARD_RECOVERY_SCORE,
CARD_RECOVERY_LINE_CHART,
CARD_RECOVERY_STATE_LIST
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) {
ViewType.CARD_RECOVERY_LINE_CHART.ordinal -> RecoveryLineChartHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_recovery_line_chart,
parent,
false
)
)
ViewType.CARD_RECOVERY_STATE_LIST.ordinal -> ListRecoveryStateListCardHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_recovery_state_list,
parent,
false
)
)
else -> RecoveryScoreHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.card_recovery_transprant,
parent,
false
)
)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = data[position]
when (getItemViewType(position)) {
ViewType.CARD_RECOVERY_LINE_CHART.ordinal -> {
(holder as RecoveryLineChartHolder).bind(item as RecoveryLineChart)
}
ViewType.CARD_RECOVERY_SCORE.ordinal -> {
(holder as RecoveryScoreHolder).bind(item as RecoveryScore)
}
else -> {
(holder as ListRecoveryStateListCardHolder).bind(data[position] as UIRecoveryState)
}
}
}
override fun getItemViewType(position: Int): Int {
return when (data[position]) {
is RecoveryLineChart -> {
ViewType.CARD_RECOVERY_LINE_CHART.ordinal
}
is RecoveryScore -> {
ViewType.CARD_RECOVERY_SCORE.ordinal
}
else -> {
ViewType.CARD_RECOVERY_STATE_LIST.ordinal
}
}
}
class RecoveryLineChartHolder(val binding: CardRecoveryLineChartBinding) :
ViewHolder(binding.root) {
fun bind(chart: RecoveryLineChart) {
binding.enter.setOnClickListener {
SleepDetailActivity.start(binding.root.context)
}
val dataSet = chart.lineChartData
dataSet.lineWidth = 3f
binding.lineChart.clearDraw()
binding.lineChart.renderer = GradientLineChartRenderer(
10,
binding.lineChart,
binding.lineChart.animator,
binding.lineChart.viewPortHandler
)
binding.lineChart.axisLeft.apply {
isEnabled = true
setDrawGridLines(false)
setDrawLabels(false)
setDrawAxisLine(false)
// 添加水平参考线
val limitLine = LimitLine(chart.targetScore, "").apply {
lineColor = binding.root.resources.getColor(
R.color.white_50,
binding.root.context.theme
)
lineWidth = 2f
enableDashedLine(10f, 10f, 0f)
}
addLimitLine(limitLine)
}
binding.lineChart.xAxis.apply {
isEnabled = true
position = XAxis.XAxisPosition.BOTTOM
axisLineColor = Color.TRANSPARENT
textColor =
binding.root.resources.getColor(R.color.white_50, binding.root.context.theme)
valueFormatter = DateValueFormatter()
}
val dataList = dataSet.values
if (!dataList.isNullOrEmpty()) {
val yAxis = binding.lineChart.axisLeft
val max = dataList.maxOfOrNull {
it.y
}
val min = dataList.minOfOrNull {
it.y
}
yAxis.axisMinimum = min!! - 2
yAxis.axisMaximum = max!! + 2
dataSet.setDrawCircles(false)
dataSet.setDrawValues(false)
val lineData = LineData(dataSet)
binding.lineChart.setData(lineData)
binding.lineChart.invalidate()
}
}
}
class ListRecoveryStateListCardHolder(val binding: CardRecoveryStateListBinding) :
ViewHolder(binding.root) {
fun bind(data: UIRecoveryState) {
binding.stateList.adapter = StateAdapter().apply {
setData(data.data)
}
binding.stateList.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.bottom =
binding.root.resources.getDimensionPixelSize(R.dimen.state_item_bottom_margin)
}
})
binding.stateList.layoutManager =
LinearLayoutManager(binding.root.context, LinearLayoutManager.VERTICAL, false)
}
}
class RecoveryScoreHolder(val binding: CardRecoveryTransprantBinding) :
ViewHolder(binding.root) {
fun bind(score: RecoveryScore) {
binding.score = score
binding.recoveryTip = score.recoveryTime
binding.date =
"${score.date.get(Calendar.MONTH) + 1}/ \n ${score.date.get(Calendar.DAY_OF_MONTH)}"
binding.tvScore.text = score.score.toString()
binding.enter.setOnClickListener {
HrvAssessmentActivity.start(binding.root.context)
}
}
}
class StateAdapter : BaseAdapter<UIRecoveryStateItem, StateAdapter.ViewHolder>() {
class ViewHolder(val binding: ItemRecoveryStateBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(data: UIRecoveryStateItem) {
Glide.with(binding.icon.context).load(data.icon).into(binding.icon)
binding.title.text = data.title
binding.description.text = data.description
binding.value.text = data.value
when (data.viewType) {
UIRecoveryStateItem.ViewType.OXYGEN, UIRecoveryStateItem.ViewType.HEAT -> {
binding.lineChart.visibility = View.VISIBLE
binding.barChart.visibility = View.INVISIBLE
binding.sleepChart.visibility = View.INVISIBLE
binding.lineChart.clearDraw()
binding.lineChart.setExtraOffsets(0f, 0f, 0f, 0f)
binding.lineChart.minOffset = 0f
//设置统计图
data.lineData?.apply {
binding.lineChart.data = LineData(data.lineData)
setDrawFilled(true)
fillDrawable =
ContextCompat.getDrawable(
binding.root.context,
R.drawable.bg_line_gradient
)
setColor(binding.root.context.getColor(R.color.heart_rate_line))
setDrawCircles(false)
setDrawValues(false)
}
binding.lineChart.invalidate()
}
UIRecoveryStateItem.ViewType.SLEEP -> {
binding.lineChart.visibility = View.INVISIBLE
binding.barChart.visibility = View.INVISIBLE
binding.sleepChart.visibility = View.VISIBLE
binding.sleepChart.setBarHeight(3F)
data.sleepData?.let { binding.value.text = (it.totalTime / 1000).toSpannableChineseTime(binding.root.context, numberTextSize = 20, unitTextSize = 14) }
data.sleepData?.let { binding.sleepChart.setSleepData(it.sleepChartData) }
}
UIRecoveryStateItem.ViewType.PRESSURE -> {
data.barData?.let {
binding.lineChart.visibility = View.INVISIBLE
binding.barChart.visibility = View.VISIBLE
binding.sleepChart.visibility = View.INVISIBLE
binding.barChart.clearDraw()
binding.barChart.setExtraOffsets(0f, 0f, 0f, 0f)
binding.barChart.minOffset = 0f
it.isHighlightEnabled = false
val barData = BarData(it)
barData.setDrawValues(false)
barData.barWidth = 0.8f // 柱体宽度,这个值越小,柱体越窄
binding.barChart.customStyle(barData.entryCount)
binding.barChart.renderer =
VerticalRadiusBarChartRender(
binding.barChart,
binding.barChart.animator,
binding.barChart.viewPortHandler
).apply {
setRadius(15)
}
binding.barChart.clearDraw()
binding.barChart.axisLeft.setAxisMinimum(0f)
binding.barChart.setVisibleXRange(0f, barData.entryCount.toFloat() + 1)
binding.barChart.setData(barData)
binding.barChart.invalidate()
}
}
UIRecoveryStateItem.ViewType.TEMPERATURE -> {
binding.lineChart.visibility = View.VISIBLE
binding.barChart.visibility = View.INVISIBLE
binding.sleepChart.visibility = View.INVISIBLE
binding.lineChart.clearDraw()
binding.lineChart.setExtraOffsets(0f, 0f, 0f, 0f)
binding.lineChart.minOffset = 0f
data.lineData?.apply {
lineWidth = 3f
mode = LineDataSet.Mode.CUBIC_BEZIER
setDrawCircles(false)
setDrawValues(false)
setDrawFilled(false)
binding.lineChart.data = LineData(this)
}
binding.lineChart.invalidate()
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.item_recovery_state,
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(data[position])
}
}
}

64
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryFragment.kt

@ -0,0 +1,64 @@
package com.whitefish.app.feature.home.recovery
import android.graphics.Rect
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import androidx.recyclerview.widget.RecyclerView.OnScrollListener
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentRecoveryBinding
import com.whitefish.app.ext.collectWith
class RecoveryFragment : BaseFragment<FragmentRecoveryBinding, RecoveryViewModel>() {
companion object {
const val TAG = "RecoveryFragment"
}
val adapter = RecoveryAdapter()
override fun bind() {
initAdapter()
collect()
}
private fun initAdapter() {
mBinding.rvRecovery.adapter = adapter
val layoutManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
mBinding.rvRecovery.layoutManager = layoutManager
mBinding.rvRecovery.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildLayoutPosition(view)
if (position != 0) {
outRect.top = resources.getDimensionPixelSize(R.dimen.recovery_item_top_margin)
}
}
})
}
private fun collect() {
mViewModel.stateList.collectWith(viewLifecycleOwner, Lifecycle.State.RESUMED) {
adapter.setData(it)
}
}
override fun setLayout(): Int {
return R.layout.fragment_recovery
}
override fun setViewModel(): RecoveryViewModel {
return ViewModelProvider(this)[RecoveryViewModel::class.java]
}
}

117
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryViewModel.kt

@ -0,0 +1,117 @@
package com.whitefish.app.feature.home.recovery
import android.icu.util.Calendar
import androidx.lifecycle.viewModelScope
import com.github.mikephil.charting.data.BarDataSet
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineDataSet
import com.orhanobut.logger.Logger
import com.whitefish.app.BaseViewModel
import com.whitefish.app.R
import com.whitefish.app.bean.UIRecoveryState
import com.whitefish.app.bean.UIRecoveryStateItem
import com.whitefish.app.bean.toState
import com.whitefish.app.constants.Constants
import com.whitefish.app.data.FakeRepository
import com.whitefish.app.device.DeviceDataProvider
import com.whitefish.app.feature.home.bean.RecoveryLineChart
import com.whitefish.app.feature.home.bean.RecoveryScore
import com.whitefish.app.utils.RefreshEmit
import com.whitefish.app.utils.SpannableTimeFormatter
import com.whitefish.app.utils.append
import com.whitefish.app.utils.createSpannableText
import com.whitefish.app.utils.getString
import com.whitefish.app.utils.toDateString
import com.whitefish.app.utils.toTimeString
import com.whitefish.app.utils.todayCalendar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import lib.linktop.nexring.api.NexRingManager
class RecoveryViewModel : BaseViewModel() {
val stateList = MutableStateFlow(emptyList<Any>())
val refreshFlow = MutableStateFlow(RefreshEmit())
val address = getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, "")
init {
collect()
}
fun collect() {
viewModelScope.launch {
refreshFlow.collectLatest {
val lastHeartRate = DeviceDataProvider.INSTANCE.getLast24HoursHeartRate()
val heartList = arrayListOf<Entry>()
val temperatureList = arrayListOf<Entry>()
val temperature = DeviceDataProvider.INSTANCE.getLastTemperature()
val sleepData = DeviceDataProvider.INSTANCE.getSleepData(todayCalendar())
for (time in 0..23) {
heartList.add(
Entry(
(23 - time).toFloat(),
(lastHeartRate[time] ?: 0).toFloat()
)
)
temperature[time]?.lastOrNull()?.let {
temperatureList.add(
Entry(
(time - 23).toFloat(),
it.value.toFloat()
)
)
}
}
heartList.apply {
sortBy { it.x }
dropLastWhile { it.y != 0F }
}
temperatureList.sortBy { it.x }
val view = arrayListOf(
RecoveryScore(
0, "预期${todayCalendar().get(Calendar.MONTH + 1)}${
todayCalendar().get(
Calendar.DAY_OF_MONTH
)
}", "", todayCalendar()
),
RecoveryLineChart(0f, LineDataSet(emptyList<Entry>(), "")),
UIRecoveryState(
arrayListOf(
UIRecoveryStateItem(
R.drawable.ic_sleep, "睡眠", "睡眠质量不错", null, null, createSpannableText("",20,true), UIRecoveryStateItem.ViewType.SLEEP, sleepData = sleepData?.toState()
),
UIRecoveryStateItem(
R.drawable.ic_heart, "心率", "您的心率正常", LineDataSet(
heartList, ""
), null, createSpannableText(heartList.last().y.toString(),20,true).append("次/每分钟",10), UIRecoveryStateItem.ViewType.HEAT
),
UIRecoveryStateItem(
R.drawable.ic_oxygen, "血氧", "您的血氧正常", LineDataSet(
arrayListOf(), ""
), null, createSpannableText("0",20,true), UIRecoveryStateItem.ViewType.OXYGEN
),
UIRecoveryStateItem(
R.drawable.ic_pressure, "压力", "您的压力正常", null, BarDataSet(
arrayListOf(), ""
), createSpannableText("0",20,true), UIRecoveryStateItem.ViewType.PRESSURE
),
UIRecoveryStateItem(
R.drawable.ic_temperature, "体温", "您的体温正常", LineDataSet(
temperatureList, ""
), null, createSpannableText(temperatureList.lastOrNull()?.y.toString(),20,true).append("",10), UIRecoveryStateItem.ViewType.TEMPERATURE
),
)
)
)
stateList.value = view
}
}
}
}

112
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingAdapter.kt

@ -0,0 +1,112 @@
package com.whitefish.app.feature.home.setting
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.databinding.ListItemSettingFullRowBlockBinding
import com.whitefish.app.databinding.ListItemSettingFullRowLineBinding
import com.whitefish.app.databinding.ListItemSettingHalfRowBlockBinding
import com.whitefish.app.databinding.ListItemSettingTitleBinding
import com.whitefish.app.feature.home.bean.Setting
import com.whitefish.app.feature.home.bean.SettingType
class SettingAdapter:BaseAdapter<Setting, SettingAdapter.SettingsViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingsViewHolder {
return when(viewType){
SettingType.FULL_ROW_BLOCK.ordinal -> {
FullRowBlockHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.list_item_setting_full_row_block,
parent,
false
)
)
}
SettingType.HALF_ROW_BLOCK.ordinal -> {
HalfRowBlockHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.list_item_setting_half_row_block,
parent,
false
)
)
}
SettingType.TITLE.ordinal -> {
TitleHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.list_item_setting_title,
parent,
false
)
)
}
else -> {
FullRowLineHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.list_item_setting_full_row_line,
parent,
false
)
)
}
}
}
override fun getItemViewType(position: Int): Int {
return when(data[position].type){
SettingType.FULL_ROW_BLOCK -> SettingType.FULL_ROW_BLOCK.ordinal
SettingType.HALF_ROW_BLOCK -> SettingType.HALF_ROW_BLOCK.ordinal
SettingType.TITLE -> SettingType.TITLE.ordinal
SettingType.FULL_ROW_LINE -> SettingType.FULL_ROW_LINE.ordinal
}
}
override fun onBindViewHolder(holder: SettingsViewHolder, position: Int) {
holder.bind(data[position])
}
abstract class SettingsViewHolder(view: View):ViewHolder(view){
abstract fun bind(item:Setting)
}
class FullRowBlockHolder(val binding:ListItemSettingFullRowBlockBinding):SettingsViewHolder(binding.root){
override fun bind(item: Setting){
binding.connectState.text = item.title
binding.powerCount.text = item.subTitle
}
}
class HalfRowBlockHolder(val binding:ListItemSettingHalfRowBlockBinding):SettingsViewHolder(binding.root){
override fun bind(item: Setting){
binding.title.text = item.title
if (!item.subTitle.isNullOrEmpty()){
binding.subTitle.text = item.subTitle
binding.subTitle.visibility = View.VISIBLE
}
Glide.with(binding.root.context).load(item.icon).into(binding.icon)
}
}
class FullRowLineHolder(val binding:ListItemSettingFullRowLineBinding):SettingsViewHolder(binding.root){
override fun bind(item: Setting){
binding.title.text = item.title
}
}
class TitleHolder(val binding: ListItemSettingTitleBinding):SettingsViewHolder(binding.root){
override fun bind(item: Setting){
binding.title.text = item.title
}
}
}

112
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingFragment.kt

@ -0,0 +1,112 @@
package com.whitefish.app.feature.home.setting
import android.graphics.Rect
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentSettingBinding
import com.whitefish.app.device.DeviceManager
import com.whitefish.app.ext.collectWith
import com.whitefish.app.ext.isLeftItem
import com.whitefish.app.feature.home.bean.Setting
import com.whitefish.app.feature.home.bean.SettingType
class SettingFragment : BaseFragment<FragmentSettingBinding, SettingViewModel>() {
companion object {
const val TAG = "SettingFragment"
private const val TOTAL_COLUM = 2
}
private val adapter = SettingAdapter()
override fun bind() {
mBinding.list.adapter = adapter
val layoutManager = GridLayoutManager(requireContext(), TOTAL_COLUM).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (adapter.getItemViewType(position)) {
SettingType.HALF_ROW_BLOCK.ordinal -> TOTAL_COLUM / 2
else -> TOTAL_COLUM
}
}
}
}
mBinding.list.layoutManager = layoutManager
mBinding.list.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
return
}
val layoutType = adapter.getItemViewType(position)
if (layoutType != SettingType.TITLE.ordinal) {
outRect.bottom =
resources.getDimensionPixelSize(R.dimen.setting_item_vertical_offset)
}
if (layoutManager.isLeftItem(position, TOTAL_COLUM)) {
outRect.right =
resources.getDimensionPixelSize(R.dimen.setting_item_horizontal_offset)
} else {
outRect.left =
resources.getDimensionPixelSize(R.dimen.setting_item_horizontal_offset)
}
}
})
DeviceManager.INSTANCE.batteryLevel.observe(this) {
adapter.setData(buildSettingList(it.second))
}
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
}
override fun onResume() {
super.onResume()
}
fun buildSettingList(powerCount: Int): List<Setting> {
return arrayListOf(
Setting("已连接", type = SettingType.FULL_ROW_BLOCK, subTitle = "$powerCount%"),
Setting("智慧生活联动", type = SettingType.HALF_ROW_BLOCK, icon = R.drawable.ic_connect_mutiple),
Setting("钱包", type = SettingType.HALF_ROW_BLOCK,icon = R.drawable.ic_wallet),
Setting("消息通知", "已开启", type = SettingType.HALF_ROW_BLOCK,icon = R.drawable.ic_notify),
Setting("找设备", type = SettingType.HALF_ROW_BLOCK,icon = R.drawable.ic_finder),
Setting("闹钟", type = SettingType.HALF_ROW_BLOCK,icon = R.drawable.ic_clock),
Setting("消息提醒", type = SettingType.HALF_ROW_BLOCK, icon = R.drawable.ic_message),
Setting("微信绑定", type = SettingType.HALF_ROW_BLOCK, icon = R.drawable.ic_wechat),
Setting("产品手册", type = SettingType.HALF_ROW_BLOCK, icon = R.drawable.ic_help),
Setting("其他", type = SettingType.TITLE),
Setting("设备设置", type = SettingType.FULL_ROW_LINE),
Setting("恢复出厂设置", type = SettingType.FULL_ROW_LINE),
Setting("固件更新", type = SettingType.FULL_ROW_LINE),
Setting("帮助与客服", type = SettingType.FULL_ROW_LINE),
Setting("设备信息", type = SettingType.FULL_ROW_LINE),
)
}
override fun setLayout(): Int {
return R.layout.fragment_setting
}
override fun setViewModel(): SettingViewModel {
return SettingViewModel()
}
}

14
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingViewModel.kt

@ -0,0 +1,14 @@
package com.whitefish.app.feature.home.setting
import androidx.lifecycle.viewModelScope
import com.orhanobut.logger.Logger
import com.whitefish.app.BaseViewModel
import com.whitefish.app.device.DeviceDataProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import lib.linktop.nexring.api.NexRingManager
import kotlin.coroutines.resume
class SettingViewModel:BaseViewModel() {
}

121
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateFragment.kt

@ -0,0 +1,121 @@
package com.whitefish.app.feature.home.state
import android.bluetooth.BluetoothProfile
import android.graphics.Rect
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
import com.orhanobut.logger.Logger
import com.whitefish.app.Application
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.bt.OnBleConnectionListener
import com.whitefish.app.databinding.FragmentStateBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.home.StateAdapter
class StateFragment : BaseFragment<FragmentStateBinding, StateViewModel>() {
private lateinit var adapter: StateAdapter
private val mOnBleConnectionListener = object : OnBleConnectionListener {
override fun onBleState(state: Int) {
if (state == BluetoothProfile.STATE_CONNECTED) {
mViewModel.refresh()
}
}
override fun onBleReady() {
mViewModel.refresh()
}
}
companion object {
const val TAG = "StateFragment"
private const val FULL_ROW = 2
private const val HALF_ROW = 1
private const val TOTAL_ROW = 2
}
override fun bind() {
initAdapter(mBinding.rvContent)
collectData()
Application.INSTANTS!!.bleManager.addOnBleConnectionListener(mOnBleConnectionListener)
}
private fun initAdapter(recyclerView: RecyclerView) {
adapter = StateAdapter()
recyclerView.adapter = adapter
recyclerView.layoutManager = GridLayoutManager(requireContext(), TOTAL_ROW).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (adapter.getItemViewType(position)) {
StateAdapter.ViewType.CARD_HEART_RATE_HALF_ROW.ordinal,
StateAdapter.ViewType.CARD_SLEEP_STATE.ordinal,
StateAdapter.ViewType.CARD_BAR_CHART_SMALL.ordinal -> {
HALF_ROW
}
else -> {
FULL_ROW
}
}
}
}
}
recyclerView.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
return
}
outRect.bottom = resources.getDimensionPixelSize(R.dimen.state_card_vertical_offset)
when (adapter.getItemViewType(position)) {
StateAdapter.ViewType.CARD_SLEEP_STATE.ordinal, StateAdapter.ViewType.CARD_HEART_RATE_HALF_ROW.ordinal, StateAdapter.ViewType.CARD_BAR_CHART_SMALL.ordinal -> {
if (position % 2 != 0) {
outRect.left =
resources.getDimensionPixelSize(R.dimen.state_card_horizontal_offset)
} else {
outRect.right =
resources.getDimensionPixelSize(R.dimen.state_card_horizontal_offset)
}
}
}
}
})
}
private fun collectData() {
mViewModel.stateList.collectWith(this, Lifecycle.State.RESUMED) {
Logger.i("view update:${it}")
adapter.setData(it)
}
}
override fun setLayout(): Int {
return R.layout.fragment_state
}
override fun setViewModel(): StateViewModel {
return ViewModelProvider(this)[StateViewModel::class.java]
}
override fun onDestroyView() {
super.onDestroyView()
Application.INSTANTS!!.bleManager.removeOnBleConnectionListener(mOnBleConnectionListener)
}
}

90
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateViewModel.kt

@ -0,0 +1,90 @@
package com.whitefish.app.feature.home.state
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineDataSet
import com.whitefish.app.bean.SleepState
import com.whitefish.app.bean.toState
import com.whitefish.app.device.DeviceDataProvider
import com.whitefish.app.feature.home.bean.ExerciseTarget
import com.whitefish.app.feature.home.bean.HeardRate
import com.whitefish.app.feature.home.bean.RecoveryScore
import com.whitefish.app.utils.RefreshEmit
import com.whitefish.app.utils.todayCalendar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.Calendar
class StateViewModel : ViewModel() {
val stateList = MutableStateFlow(emptyList<Any>())
private val _refreshFlow = MutableStateFlow(RefreshEmit())
init {
collect()
}
fun refresh() {
viewModelScope.launch {
_refreshFlow.value = RefreshEmit()
}
}
fun collect() {
viewModelScope.launch {
_refreshFlow.collectLatest {
val exerciseTarget = ExerciseTarget(0, "0")
val recoveryCard = RecoveryScore(
score = 0,
recoveryTime = "0",
"你当天的身体状态似乎不太好,请注意保持适量的运动,晚上早点休息,养成良好的锻炼和睡眠规律。",
todayCalendar()
)
val hrList = DeviceDataProvider.INSTANCE.getHeartRate(todayCalendar())
val sleepData = DeviceDataProvider.INSTANCE.getSleepData(todayCalendar())
stateList.value = processViewData(exerciseTarget, recoveryCard, hrList, sleepData?.toState())
}
}
}
private fun processViewData(
exerciseTarget: ExerciseTarget,
recoveryCard: RecoveryScore,
hrData: List<Pair<Int, Int>>,
sleepData: SleepState?
): List<Any> {
val today = todayCalendar()
val viewList = arrayListOf<Any>()
val hrEntry = arrayListOf<Entry>()
hrData.forEachIndexed { index, item ->
hrEntry.add(Entry(item.first.toFloat(), item.second.toFloat()))
}
val hrLineChart = LineDataSet(hrEntry, "")
val hrView = HeardRate(
HeardRate.SIZE_HALF_ROW,
hrLineChart,
(hrData.lastOrNull()?.second ?: 0).toString(),
"${today.get(Calendar.MONTH) + 1}/${today.get(Calendar.DAY_OF_MONTH)}", ""
)
viewList.add(exerciseTarget)
viewList.add(recoveryCard)
viewList.add(hrView)
if (sleepData != null) {
viewList.add(sleepData)
} else {
viewList.add(
SleepState(
"${today.get(Calendar.MONTH) + 1}/${today.get(Calendar.DAY_OF_MONTH)}", 0L,
emptyList(), "0", 0F, "0", "0", "0"
)
)
}
return viewList
}
}

26
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/launcher/LauncherActivity.kt

@ -0,0 +1,26 @@
package com.whitefish.app.feature.launcher
import com.whitefish.app.BaseActivity
import com.whitefish.app.BaseViewModel
import com.whitefish.app.R
import com.whitefish.app.databinding.ActivityLauncherBinding
import com.whitefish.app.feature.login.LoginActivity
class LauncherActivity: BaseActivity<ActivityLauncherBinding,BaseViewModel>() {
override fun setLayout(): Int {
return R.layout.activity_launcher
}
override fun setViewModel(): Class<BaseViewModel> {
return BaseViewModel::class.java
}
override fun bind() {
mBinding.btUse.setOnClickListener {
LoginActivity.start(this)
finish()
}
}
}

34
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/login/LoginActivity.kt

@ -0,0 +1,34 @@
package com.whitefish.app.feature.login
import android.content.Context
import android.content.Intent
import com.whitefish.app.BaseActivity
import com.whitefish.app.BaseViewModel
import com.whitefish.app.R
import com.whitefish.app.databinding.ActivityLoginBinding
import com.whitefish.app.feature.connect.ConnectTipActivity
import com.whitefish.app.feature.devices.DeviceActivity
class LoginActivity:BaseActivity<ActivityLoginBinding,BaseViewModel>() {
companion object{
fun start(context:Context){
val intent = Intent(context,LoginActivity::class.java)
context.startActivity(intent)
}
}
override fun setLayout(): Int {
return R.layout.activity_login
}
override fun setViewModel(): Class<BaseViewModel> {
return BaseViewModel::class.java
}
override fun bind() {
mBinding.btLogin.setOnClickListener {
ConnectTipActivity.start(this)
finish()
}
}
}

32
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanActivity.kt

@ -0,0 +1,32 @@
package com.whitefish.app.feature.plan
import android.content.Context
import android.content.Intent
import com.whitefish.app.BaseActivity
import com.whitefish.app.BaseViewModel
import com.whitefish.app.R
import com.whitefish.app.databinding.ActivityPlanBinding
import com.whitefish.app.feature.home.HomeActivity
class PlanActivity:BaseActivity<ActivityPlanBinding,PlanViewModel>() {
override fun setLayout(): Int {
return R.layout.activity_plan
}
override fun setViewModel(): Class<PlanViewModel> {
return PlanViewModel::class.java
}
override fun bind() {
mBinding.done.setOnClickListener {
HomeActivity.start(this)
finish()
}
}
companion object{
fun start(context:Context){
context.startActivity(Intent(context,PlanActivity::class.java))
}
}
}

6
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanViewModel.kt

@ -0,0 +1,6 @@
package com.whitefish.app.feature.plan
import com.whitefish.app.BaseViewModel
class PlanViewModel:BaseViewModel() {
}

105
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceActivity.kt

@ -0,0 +1,105 @@
package com.whitefish.app.feature.preference
import android.content.Context
import android.content.Intent
import android.util.Log
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import com.whitefish.app.BaseActivity
import com.whitefish.app.R
import com.whitefish.app.databinding.ActivityPreferenceBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.preference.fragment.LikeFragment
import com.whitefish.app.feature.preference.fragment.PromoteFragment
import com.whitefish.app.feature.preference.fragment.RemindFragment
import com.whitefish.app.feature.preference.fragment.TargetFragment
import com.whitefish.app.feature.preference.fragment.WeightFragment
class PreferenceActivity : BaseActivity<ActivityPreferenceBinding, PreferenceViewModel>() {
enum class ViewType {
TYPE_TARGET,
TYPE_PROMOTE,
TYPE_WEIGHT,
TYPE_LIKE,
TYPE_REMIND
}
companion object{
private const val TAG = "PreferenceActivity"
fun start(context:Context){
val intent = Intent(context,PreferenceActivity::class.java)
context.startActivity(intent)
}
}
override fun setLayout(): Int {
return R.layout.activity_preference
}
override fun setViewModel(): Class<PreferenceViewModel> {
return PreferenceViewModel::class.java
}
override fun bind() {
showFragment(ViewType.TYPE_WEIGHT)
collect()
}
fun collect(){
mViewModel.weightFlow.collectWith(this,Lifecycle.State.RESUMED){
if (it.isNotEmpty()){
mBinding.tvPreferenceWeight.text = "体重 : $it"
mBinding.tvPreferenceWeight.visibility = View.VISIBLE
mBinding.lpvProgress.setProgress(mBinding.lpvProgress.getProgress() + 25)
}
}
mViewModel.targetFlow.collectWith(this,Lifecycle.State.RESUMED){
if (it.isNotEmpty()){
mBinding.tvPreferenceExerciseTarget.text = "运动目标 : $it"
mBinding.tvPreferenceExerciseTarget.visibility = View.VISIBLE
mBinding.lpvProgress.setProgress(mBinding.lpvProgress.getProgress() + 25)
}
}
mViewModel.promoteFlow.collectWith(this,Lifecycle.State.RESUMED){
if (it.isNotEmpty()){
mBinding.tvPreferencePromoteTarget.text = "提升目标 : $it"
mBinding.tvPreferencePromoteTarget.visibility = View.VISIBLE
mBinding.lpvProgress.setProgress(mBinding.lpvProgress.getProgress() + 25)
}
}
}
fun showFragment(tag: ViewType) {
supportFragmentManager.beginTransaction().replace(R.id.flPreferenceFragmentContainer,obtainFragment(tag)).commit()
}
fun obtainFragment(tag: ViewType): Fragment {
Log.d(TAG,"obtain fragment:${tag}")
return when (tag) {
ViewType.TYPE_TARGET -> {
TargetFragment()
}
ViewType.TYPE_WEIGHT -> {
WeightFragment()
}
ViewType.TYPE_LIKE -> {
LikeFragment()
}
ViewType.TYPE_PROMOTE -> {
PromoteFragment()
}
ViewType.TYPE_REMIND -> {
mBinding.clTopArray.visibility = View.GONE
mBinding.tvSkip.text = "下次再说"
RemindFragment()
}
}
}
}

93
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceViewModel.kt

@ -0,0 +1,93 @@
package com.whitefish.app.feature.preference
import androidx.lifecycle.viewModelScope
import com.whitefish.app.BaseViewModel
import com.whitefish.app.data.FakeRepository
import com.whitefish.app.utils.RefreshEmit
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class PreferenceViewModel : BaseViewModel() {
val weightFlow = MutableStateFlow("")
val targetFlow = MutableStateFlow("")
val promoteFlow = MutableStateFlow("")
val exerciseList = MutableStateFlow(emptyList<String>())
val weightList = arrayListOf<String>().apply {
for (i in 1..100) {
add("$i kg")
}
}
val targetList = MutableStateFlow(emptyList<String>())
val promoteList = MutableStateFlow(emptyList<String>())
private val refreshFlow = MutableStateFlow(RefreshEmit())
init {
collect()
}
fun refresh(type: PreferenceActivity.ViewType) {
viewModelScope.launch {
refreshFlow.value = RefreshEmit(type.ordinal)
}
}
private fun collect() {
viewModelScope.launch {
refreshFlow.collect {
when(it.code){
PreferenceActivity.ViewType.TYPE_PROMOTE.ordinal -> {
promoteList.value = arrayListOf(
"跑步能力",
"速度和爆发力",
"心肺能力",
"肌肉量和体脂率",
"协调性和柔韧性",
"耐力"
)
}
PreferenceActivity.ViewType.TYPE_TARGET.ordinal -> {
targetList.value = arrayListOf(
"全身减脂减重",
"局部变瘦,紧致塑型",
"增加肌肉,雕塑线条",
"提升专业运动能力/成绩",
"保持身体健康",
"生病复健"
)
}
PreferenceActivity.ViewType.TYPE_LIKE.ordinal -> {
exerciseList.value = arrayListOf(
"跑走骑运动","球类运动","徒手有氧训练(HIIT 等","无器械塑型力量训练","全身热身","拉伸运动","瑜伽"
)
}
}
}
}
}
fun setSelectValue(value: String, type: PreferenceActivity.ViewType) {
viewModelScope.launch {
when (type) {
PreferenceActivity.ViewType.TYPE_PROMOTE -> {
promoteFlow.value = value
}
PreferenceActivity.ViewType.TYPE_TARGET -> {
targetFlow.value = value
}
PreferenceActivity.ViewType.TYPE_WEIGHT ->{
weightFlow.value = value
}
else -> {}
}
}
}
}

28
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeAdapter.kt

@ -0,0 +1,28 @@
package com.whitefish.app.feature.preference.fragment
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.databinding.ListItemLikeBinding
class LikeAdapter:BaseAdapter<String,LikeAdapter.ListItemLikeHolder>() {
inner class ListItemLikeHolder(private val binding: ListItemLikeBinding): RecyclerView.ViewHolder(binding.root){
fun bind(data:String){
binding.data = data
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemLikeHolder {
return ListItemLikeHolder(
DataBindingUtil.inflate(LayoutInflater.from(parent.context), R.layout.list_item_like,parent,false)
)
}
override fun onBindViewHolder(holder: ListItemLikeHolder, position: Int) {
holder.bind(data[position])
}
}

95
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeFragment.kt

@ -0,0 +1,95 @@
package com.whitefish.app.feature.preference.fragment
import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentLikeBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.preference.PreferenceActivity
import com.whitefish.app.feature.preference.PreferenceViewModel
class LikeFragment: BaseFragment<FragmentLikeBinding,PreferenceViewModel>() {
private val viewModel: PreferenceViewModel by activityViewModels()
private val row1Adapter = LikeAdapter()
private val row2Adapter = LikeAdapter()
private val row3Adapter = LikeAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bind()
mViewModel.refresh(PreferenceActivity.ViewType.TYPE_LIKE)
}
override fun bind(){
initAdapter()
collect()
mBinding.tvDone.setOnClickListener {
(requireActivity() as PreferenceActivity).showFragment(PreferenceActivity.ViewType.TYPE_REMIND)
}
}
override fun setLayout(): Int {
return R.layout.fragment_like
}
override fun setViewModel(): PreferenceViewModel {
return viewModel
}
private fun collect(){
mViewModel.exerciseList.collectWith(viewLifecycleOwner,Lifecycle.State.RESUMED){
val chunks = it.chunked(it.size / 3)
row1Adapter.setData(chunks.first())
row2Adapter.setData(chunks[1])
row3Adapter.setData(chunks.last())
}
}
private fun initAdapter(){
mBinding.row1.adapter = row1Adapter
mBinding.row1.setLayoutManager(LinearLayoutManager(requireContext(),LinearLayoutManager.HORIZONTAL,false))
mBinding.row2.adapter = row2Adapter
mBinding.row2.setLayoutManager(LinearLayoutManager(requireContext(),LinearLayoutManager.HORIZONTAL,false))
mBinding.row3.adapter = row3Adapter
mBinding.row3.setLayoutManager(LinearLayoutManager(requireContext(),LinearLayoutManager.HORIZONTAL,false))
setupSyncedRecyclerViews(mBinding.row1,mBinding.row2,mBinding.row3)
}
private fun setupSyncedRecyclerViews(recyclerView1: RecyclerView, recyclerView2: RecyclerView, recyclerView3: RecyclerView) {
val allRecyclerViews = listOf(recyclerView1, recyclerView2, recyclerView3)
allRecyclerViews.forEach { recyclerView ->
recyclerView.addOnScrollListener(SyncScrollListener(allRecyclerViews.filter { it != recyclerView }))
}
}
class SyncScrollListener(private val syncRecyclerViews: List<RecyclerView>) : RecyclerView.OnScrollListener() {
private var isScrolling = false
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (isScrolling) {
return
}
// 同步其他的RecyclerView的滚动位置
syncRecyclerViews.forEach { otherRecyclerView ->
if (otherRecyclerView != recyclerView) {
isScrolling = true
otherRecyclerView.scrollBy(dx, dy)
isScrolling = false
}
}
}
}
}

53
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/PromoteFragment.kt

@ -0,0 +1,53 @@
package com.whitefish.app.feature.preference.fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentSelectRadiusListBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.preference.PreferenceActivity
import com.whitefish.app.feature.preference.PreferenceViewModel
class PromoteFragment:BaseFragment<FragmentSelectRadiusListBinding,PreferenceViewModel>() {
private val viewModel:PreferenceViewModel by activityViewModels()
private val adapter = SelectAdapter(false)
override fun bind(){
collect()
initAdapter()
mViewModel.refresh(PreferenceActivity.ViewType.TYPE_PROMOTE)
}
override fun setLayout(): Int {
return R.layout.fragment_select_radius_list
}
override fun setViewModel(): PreferenceViewModel {
return viewModel
}
private fun collect(){
mViewModel.promoteList.collectWith(viewLifecycleOwner,Lifecycle.State.RESUMED){
adapter.setData(it)
}
mBinding.tvDone.setOnClickListener {
adapter.getSelectValue()?.let {
mViewModel.setSelectValue(it,PreferenceActivity.ViewType.TYPE_PROMOTE)
(requireActivity() as PreferenceActivity).apply {
showFragment(PreferenceActivity.ViewType.TYPE_LIKE)
}
}
}
}
private fun initAdapter(){
mBinding.rvList.adapter = adapter
mBinding.rvList.layoutManager = LinearLayoutManager(requireContext())
}
}

44
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindFragment.kt

@ -0,0 +1,44 @@
package com.whitefish.app.feature.preference.fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentRemindBinding
import com.whitefish.app.feature.plan.PlanActivity
class RemindFragment : BaseFragment<FragmentRemindBinding, RemindViewModel>() {
override fun bind() {
initAdapter()
mBinding.tvDone.setOnClickListener {
PlanActivity.start(requireContext())
}
}
private fun initAdapter() {
mBinding.rvList.adapter = SelectAdapter(true)
.apply {
setData(
listOf(
"每周一",
"每周二",
"每周三",
"每周四",
"每周五",
"每周六",
"每周日",
)
)
}
mBinding.rvList.layoutManager = LinearLayoutManager(requireContext())
}
override fun setLayout(): Int {
return R.layout.fragment_remind
}
override fun setViewModel(): RemindViewModel {
return ViewModelProvider(this)[RemindViewModel::class.java]
}
}

6
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindViewModel.kt

@ -0,0 +1,6 @@
package com.whitefish.app.feature.preference.fragment
import com.whitefish.app.BaseViewModel
class RemindViewModel:BaseViewModel() {
}

80
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/SelectAdapter.kt

@ -0,0 +1,80 @@
package com.whitefish.app.feature.preference.fragment
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.bumptech.glide.Glide
import com.whitefish.app.BaseAdapter
import com.whitefish.app.R
import com.whitefish.app.databinding.ListItemSelectRadiusBinding
class SelectAdapter(val checkBox:Boolean):BaseAdapter<String, SelectAdapter.ListItemTargetHolder>() {
private var selectedPosition = -1
private val selectMap = HashMap<Int,String>()
fun getSelectValue() = if (selectedPosition == -1){null}else{data[selectedPosition]}
fun getSelectList() = selectMap.values.toList()
private fun setOnSelect(position: Int){
if (checkBox){
if (selectMap[position] == null){
selectMap[position] = data[position]
}else{
selectMap.remove(position)
}
notifyItemChanged(position)
}else{
val oldPosition = selectedPosition
selectedPosition = position
notifyItemChanged(oldPosition)
notifyItemChanged(selectedPosition)
}
}
override fun setData(newData:List<String>, diffUpdate:Boolean){
super.setData(newData,diffUpdate)
selectedPosition = -1
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemTargetHolder {
return ListItemTargetHolder(
DataBindingUtil.inflate(LayoutInflater.from(parent.context), R.layout.list_item_select_radius,parent,false)
)
}
override fun onBindViewHolder(holder: ListItemTargetHolder, position: Int) {
holder.bind(data[position],position)
}
inner class ListItemTargetHolder(private val binding: ListItemSelectRadiusBinding):ViewHolder(binding.root){
fun bind(data:String,position: Int){
binding.item.setOnClickListener {
setOnSelect(position)
}
binding.data = data
if (checkBox){
if (selectMap[position] != null){
Glide.with(binding.root.context).load(AppCompatResources.getDrawable(binding.root.context,R.drawable.item_checkbox_selected)).into(binding.ivSelectState)
}else{
Glide.with(binding.root.context).load(AppCompatResources.getDrawable(binding.root.context,R.drawable.item_checkbox_unselected)).into(binding.ivSelectState)
}
}else{
if (position == selectedPosition){
Glide.with(binding.root.context).load(AppCompatResources.getDrawable(binding.root.context,R.drawable.item_radius_selected)).into(binding.ivSelectState)
}else{
Glide.with(binding.root.context).load(AppCompatResources.getDrawable(binding.root.context,R.drawable.item_radius_unselect)).into(binding.ivSelectState)
}
}
}
}
}

50
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/TargetFragment.kt

@ -0,0 +1,50 @@
package com.whitefish.app.feature.preference.fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentSelectRadiusListBinding
import com.whitefish.app.ext.collectWith
import com.whitefish.app.feature.preference.PreferenceActivity
import com.whitefish.app.feature.preference.PreferenceViewModel
class TargetFragment:BaseFragment<FragmentSelectRadiusListBinding,PreferenceViewModel>() {
private val viewModel: PreferenceViewModel by activityViewModels()
private val adapter = SelectAdapter(false)
override fun bind(){
collect()
initAdapter()
mViewModel.refresh(PreferenceActivity.ViewType.TYPE_TARGET)
mBinding.tvDone.setOnClickListener {
adapter.getSelectValue()?.let {
mViewModel.setSelectValue(it,PreferenceActivity.ViewType.TYPE_TARGET)
(requireActivity() as PreferenceActivity).showFragment(PreferenceActivity.ViewType.TYPE_PROMOTE)
}
}
}
override fun setLayout(): Int {
return R.layout.fragment_select_radius_list
}
override fun setViewModel(): PreferenceViewModel {
return viewModel
}
private fun collect(){
mViewModel.targetList.collectWith(viewLifecycleOwner,Lifecycle.State.RESUMED){
adapter.setData(it)
}
}
private fun initAdapter(){
mBinding.rvList.adapter = adapter
mBinding.rvList.layoutManager = LinearLayoutManager(requireContext())
}
}

40
composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/WeightFragment.kt

@ -0,0 +1,40 @@
package com.whitefish.app.feature.preference.fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.whitefish.app.BaseFragment
import com.whitefish.app.R
import com.whitefish.app.databinding.FragmentWeightBinding
import com.whitefish.app.feature.preference.PreferenceActivity
import com.whitefish.app.feature.preference.PreferenceViewModel
class WeightFragment : BaseFragment<FragmentWeightBinding, PreferenceViewModel>() {
private val viewModel: PreferenceViewModel by activityViewModels()
override fun bind() {
initAdapter()
mBinding.tvDone.setOnClickListener {
mViewModel.setSelectValue(
mBinding.spvList.currentValue,
PreferenceActivity.ViewType.TYPE_WEIGHT
)
(requireActivity() as PreferenceActivity).showFragment(PreferenceActivity.ViewType.TYPE_TARGET)
}
mBinding.tvSkip.setOnClickListener {
(requireActivity() as PreferenceActivity).showFragment(PreferenceActivity.ViewType.TYPE_TARGET)
}
}
override fun setLayout(): Int {
return R.layout.fragment_weight
}
override fun setViewModel(): PreferenceViewModel {
return viewModel
}
private fun initAdapter() {
mBinding.spvList.setData(mViewModel.weightList, 50)
mBinding.spvList.layoutManager = LinearLayoutManager(requireContext())
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save