commit e8644c9d7a68f384fd071a3fa5c30f3d79cbd779 Author: AnranYus Date: Wed May 28 22:57:34 2025 +0800 KMP project init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d9c0e4 --- /dev/null +++ b/.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ba257b --- /dev/null +++ b/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)… \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..75f6372 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 0000000..86e9131 --- /dev/null +++ b/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) +} + diff --git a/composeApp/keystore.jks b/composeApp/keystore.jks new file mode 100644 index 0000000..9de4451 Binary files /dev/null and b/composeApp/keystore.jks differ diff --git a/composeApp/libs/NexRingSDK_v1.4.0_release.aar b/composeApp/libs/NexRingSDK_v1.4.0_release.aar new file mode 100644 index 0000000..4582983 Binary files /dev/null and b/composeApp/libs/NexRingSDK_v1.4.0_release.aar differ diff --git a/composeApp/libs/OemAuth_v2.0.0_release.aar b/composeApp/libs/OemAuth_v2.0.0_release.aar new file mode 100644 index 0000000..c0a0b24 Binary files /dev/null and b/composeApp/libs/OemAuth_v2.0.0_release.aar differ diff --git a/composeApp/libs/SleepStagingNativeLib_v5_ring_release_v2.5.6.1.aar b/composeApp/libs/SleepStagingNativeLib_v5_ring_release_v2.5.6.1.aar new file mode 100644 index 0000000..5f1bc4d Binary files /dev/null and b/composeApp/libs/SleepStagingNativeLib_v5_ring_release_v2.5.6.1.aar differ diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..0c74339 --- /dev/null +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/Application.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/Application.kt new file mode 100644 index 0000000..2cd630a --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseActivity.kt new file mode 100644 index 0000000..f6be330 --- /dev/null +++ b/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 : 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 + abstract fun bind() + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseAdapter.kt new file mode 100644 index 0000000..af4d237 --- /dev/null +++ b/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 : RecyclerView.Adapter() { + + val data = arrayListOf() + + open fun setData(newData: List, 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 +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseFragment.kt new file mode 100644 index 0000000..038fbc5 --- /dev/null +++ b/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: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 +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/BaseViewModel.kt new file mode 100644 index 0000000..5f1abe5 --- /dev/null +++ b/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() { +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/Platform.android.kt new file mode 100644 index 0000000..a6eddf8 --- /dev/null +++ b/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() \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/AnimalType.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/AnimalType.kt new file mode 100644 index 0000000..eb44c34 --- /dev/null +++ b/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, +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/BiologicalClock.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/BiologicalClock.kt new file mode 100644 index 0000000..0322843 --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Device.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Device.kt new file mode 100644 index 0000000..137e771 --- /dev/null +++ b/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) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Evaluate.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Evaluate.kt new file mode 100644 index 0000000..70b90da --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Exercise.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/Exercise.kt new file mode 100644 index 0000000..d57017e --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/RecoverySleep.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/RecoverySleep.kt new file mode 100644 index 0000000..60d87c3 --- /dev/null +++ b/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, +) diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepData.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepData.kt new file mode 100644 index 0000000..c53fed7 --- /dev/null +++ b/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 , +) diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepState.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/SleepState.kt new file mode 100644 index 0000000..e3d8898 --- /dev/null +++ b/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 = 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{ + 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().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, +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UIRecoveryState.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UIRecoveryState.kt new file mode 100644 index 0000000..7f8b8b2 --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UserInfo.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bean/UserInfo.kt new file mode 100644 index 0000000..26b98b3 --- /dev/null +++ b/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 = emptyList(), + val wear:String? = null, + val handedness:String? = null, +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleDevice.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleDevice.kt new file mode 100644 index 0000000..9d18098 --- /dev/null +++ b/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, +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleManager.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/BleManager.kt new file mode 100644 index 0000000..47553c8 --- /dev/null +++ b/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 = ArrayList() + + private var mOnBleScanCallback: OnBleScanCallback? = null + var bleGatt: BluetoothGatt? = null + private val scanDevMacList: MutableList = 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() + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleConnectionListener.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleConnectionListener.kt new file mode 100644 index 0000000..8c1b70b --- /dev/null +++ b/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() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleScanCallback.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/bt/OnBleScanCallback.kt new file mode 100644 index 0000000..79c6640 --- /dev/null +++ b/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() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/DateValueFormatter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/DateValueFormatter.kt new file mode 100644 index 0000000..d20063a --- /dev/null +++ b/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 { + "" // 其他位置不显示 + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/GradientLineChartRenderer.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/GradientLineChartRenderer.kt new file mode 100644 index 0000000..872dfef --- /dev/null +++ b/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() + 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) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/ResizedDrawable.java b/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/ResizedDrawable.java new file mode 100644 index 0000000..86e03ef --- /dev/null +++ b/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(); + } +} + diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/RightAlignedValueFormatter.java b/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/RightAlignedValueFormatter.java new file mode 100644 index 0000000..d8bee9b --- /dev/null +++ b/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() + "岁"; + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/VerticalRadiusBarChartRender.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/chart/VerticalRadiusBarChartRender.kt new file mode 100644 index 0000000..4e0287b --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/constants/Constants.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/constants/Constants.kt new file mode 100644 index 0000000..4bee1d7 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/HeartRateDao.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/HeartRateDao.kt new file mode 100644 index 0000000..b6efa65 --- /dev/null +++ b/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) + + @Query("select * from HeartRateDataBean order by time desc") + suspend fun getHeartRateList():List +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/SleepDao.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/SleepDao.kt new file mode 100644 index 0000000..156cee1 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/WorkoutDao.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/WorkoutDao.kt new file mode 100644 index 0000000..cb8c497 --- /dev/null +++ b/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 + + @Query("SELECT * FROM WorkoutDbData WHERE date = :date") + suspend fun getWorkoutsByDate(date: String): List +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/HeartRateDataBean.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/HeartRateDataBean.kt new file mode 100644 index 0000000..498e676 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/SleepDataBean.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/SleepDataBean.kt new file mode 100644 index 0000000..0bc29fb --- /dev/null +++ b/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 = emptyList(), + val score: String = "", + val rating:Float = 0f, +) : Parcelable \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/WorkoutDbData.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/bean/WorkoutDbData.kt new file mode 100644 index 0000000..f299245 --- /dev/null +++ b/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, +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/converter/SleepSegmentConverter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/dao/converter/SleepSegmentConverter.kt new file mode 100644 index 0000000..80ce408 --- /dev/null +++ b/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>() {}.type + + @TypeConverter + fun fromString(value: String): List { + return gson.fromJson(value, type) + } + + @TypeConverter + fun fromList(list: List): String { + return gson.toJson(list) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/data/FakeRepository.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/data/FakeRepository.kt new file mode 100644 index 0000000..8eabea3 --- /dev/null +++ b/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 { +// return arrayListOf(Device("", "Test", "123"), Device("", "Test", "123")) +// } + +// suspend fun stateList(): List { +// return arrayListOf( +// ExerciseTarget(35, "1234"), +// RecoveryScore( +// 55, +// "预计2月31日00:00后\n完全恢复", +// "啊八八八八八八Abba艾伯克兰十大事件都可能", +// Date(System.currentTimeMillis()) +// ), +// HeardRate( +// HeardRate.SIZE_HALF_ROW, LineDataSet( +// arrayListOf( +// 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 { + return arrayListOf( +// RecoveryScore( +// 55, +// "预计2/32日18:00后完全恢复", +// "你当天的身体状态似乎不太好,请注意保持适量的运动,晚上早点休息,养成良好的锻炼和睡眠规律。", +// Date(System.currentTimeMillis()) +// ), + RecoveryLineChart( + -1f, + lineChartData = LineDataSet( + arrayListOf( + 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 { + return arrayListOf( + "10", + "20", + "30", + "40", + "50", + "60" + ) + } + + suspend fun distanceListData(): List { + return arrayListOf( + "10", + "20", + "30", + "40", + "50", + "60" + ) + } + + suspend fun targetList(): List { + return arrayListOf( + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + "全身减脂", + + ) + } + + suspend fun promoteList(): List { + return arrayListOf( + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + "速度和爆发力", + + ) + } + + suspend fun exerciseList(): List { + 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) +// ) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/db/AppDatabase.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/db/AppDatabase.kt new file mode 100644 index 0000000..100e670 --- /dev/null +++ b/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 + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceDataProvider.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceDataProvider.kt new file mode 100644 index 0000000..e3cdaa1 --- /dev/null +++ b/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> = 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> { + val heartRates = mutableListOf>() + 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 = 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 返回最近24小时的数据,key为距离当前时间的小时数,value为对应的数据 + */ + private suspend fun getLast24HoursData( + dataFetcher: (startTime: Long, endTime: Long) -> T? + ): HashMap = scope.async { + val currentTime = System.currentTimeMillis() + val result = hashMapOf() + + 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 { + return getLast24HoursData { startTime, endTime -> + var hourlyRate: Int? = null + NexRingManager.get().sleepApi().getHrList(address, startTime, endTime) { result -> + result?.let { + hourlyRate = it.first.avg.toInt() + } + } + hourlyRate + } + } + + /** + * 获取最近24小时的Spo2数据 + * @return 返回最近24小时的Spo2数据列表,每个元素为 Pair(距离当前时间的小时数, 该小时的平均Spo2值) + */ + suspend fun getLast24HoursSpo2(): HashMap> { + return getLast24HoursData { startTime, endTime -> + var spo2Data: List? = 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 = suspendCoroutine { continuation -> + NexRingManager.get().sleepApi().getHrList( + address, + startTs, + endTs + ) { + it?.let { it1 -> continuation.resume(it1.second) }?: continuation.resume(emptyList()) + } + } + + suspend fun getLastTemperature(): HashMap> { + return getLast24HoursData { startTime, endTime -> + var spo2Data: List? = 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>? = + 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) + } + } +} + diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceManager.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/device/DeviceManager.kt new file mode 100644 index 0000000..641c937 --- /dev/null +++ b/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() + + 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.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() + } + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/device/ECGViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/device/ECGViewModel.kt new file mode 100644 index 0000000..51ad273 --- /dev/null +++ b/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(null) + val countdownLd = MutableLiveData(null) + var ecgResult: ECGResult? = null + set(value) { + if (field != value) { + field = value + resultLd.postValue(value) + } + } + val resultLd = MutableLiveData(ecgResult) + val ecgDataList = ArrayList() + 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 + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Chart.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Chart.kt new file mode 100644 index 0000000..24ebf9b --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Lifecycle.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/ext/Lifecycle.kt new file mode 100644 index 0000000..0cd9236 --- /dev/null +++ b/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 Flow.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 + } + } + }) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/ext/List.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/ext/List.kt new file mode 100644 index 0000000..574d761 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/connect/ConnectTipActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/connect/ConnectTipActivity.kt new file mode 100644 index 0000000..311d5fb --- /dev/null +++ b/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() { + 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 { + 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? { + val dinedPermissions: MutableList = ArrayList() + for (permission in permissions) { + if (ActivityCompat.checkSelfPermission(context, permission) + != PackageManager.PERMISSION_GRANTED + ) { + dinedPermissions.add(permission) + } + } + return if (dinedPermissions.isEmpty()) null else dinedPermissions.toTypedArray() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DeviceActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DeviceActivity.kt new file mode 100644 index 0000000..b0e2109 --- /dev/null +++ b/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() { + 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 { + 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) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesAdapter.kt new file mode 100644 index 0000000..fe5aca3 --- /dev/null +++ b/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() { + 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() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/devices/DevicesViewModel.kt new file mode 100644 index 0000000..d91a9ba --- /dev/null +++ b/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(0) + internal val viewState: SharedFlow = _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() + 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) : + ViewState() + + data class Error(val error: Exception) : ViewState() + data object Empty : ViewState() + data object Default: ViewState() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeActivity.kt new file mode 100644 index 0000000..394e732 --- /dev/null +++ b/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() { + 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 { + 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() + } + } + + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..b210461 --- /dev/null +++ b/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}") + } + } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/StateAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/StateAdapter.kt new file mode 100644 index 0000000..9f9a71c --- /dev/null +++ b/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() { + + 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) diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/BloodOxygen.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/BloodOxygen.kt new file mode 100644 index 0000000..00fb91f --- /dev/null +++ b/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) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseHistory.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseHistory.kt new file mode 100644 index 0000000..71e5185 --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseTarget.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/ExerciseTarget.kt new file mode 100644 index 0000000..9ac57f3 --- /dev/null +++ b/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) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HalfRowBarChart.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HalfRowBarChart.kt new file mode 100644 index 0000000..7839a6f --- /dev/null +++ b/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() + data.values.forEach{ barEntry -> + colors.add(valueColorMap.invoke(barEntry)) + } + data.colors = colors + return HalfRowBarChart(title,date,detail,suffix, data) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeardRate.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeardRate.kt new file mode 100644 index 0000000..ca1e18b --- /dev/null +++ b/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 + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeartHealth.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/HeartHealth.kt new file mode 100644 index 0000000..6d01b5f --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryLineChart.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryLineChart.kt new file mode 100644 index 0000000..d4c3465 --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryScore.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/RecoveryScore.kt new file mode 100644 index 0000000..055cfba --- /dev/null +++ b/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, +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Row.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Row.kt new file mode 100644 index 0000000..0dafffb --- /dev/null +++ b/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) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Setting.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Setting.kt new file mode 100644 index 0000000..9c8f49e --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Temperature.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Temperature.kt new file mode 100644 index 0000000..b55bc38 --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Tips.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/bean/Tips.kt new file mode 100644 index 0000000..75b574d --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/AddExerciseDialog.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/AddExerciseDialog.kt new file mode 100644 index 0000000..fca6621 --- /dev/null +++ b/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 = "距离" + } + } + } + } + + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/DialogExerciseViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/DialogExerciseViewModel.kt new file mode 100644 index 0000000..97a9072 --- /dev/null +++ b/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()) + 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() + } + } + + } + } + + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseAdapter.kt new file mode 100644 index 0000000..17e45d3 --- /dev/null +++ b/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() { + 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) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseDialog.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseDialog.kt new file mode 100644 index 0000000..0e0efb8 --- /dev/null +++ b/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() + + + 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() + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseFragment.kt new file mode 100644 index 0000000..7da879a --- /dev/null +++ b/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() { + 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] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/ExerciseViewModel.kt new file mode 100644 index 0000000..cad1f0f --- /dev/null +++ b/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>? { + val lastWorkout = dao.getAllWorkouts().firstOrNull() + Logger.i("last work out is:${lastWorkout}") + var result: Pair>? = null + lastWorkout?.let { + NexRingManager.get().sleepApi().getWorkoutData( + getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, ""), lastWorkout.startTs, + lastWorkout.endTs + ) { + result = it + } + } + + return result + + } + +// fun loadLastExercise +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SelectDialog.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SelectDialog.kt new file mode 100644 index 0000000..c9b9898 --- /dev/null +++ b/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): 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)) + + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SetTargetDialog.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/exercise/SetTargetDialog.kt new file mode 100644 index 0000000..406c766 --- /dev/null +++ b/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) + } + + + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/DateAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/DateAdapter.kt new file mode 100644 index 0000000..c3df215 --- /dev/null +++ b/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() { + + 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]) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryAdapter.kt new file mode 100644 index 0000000..81e3efe --- /dev/null +++ b/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() { + + 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() { + 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]) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryFragment.kt new file mode 100644 index 0000000..3256287 --- /dev/null +++ b/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() { + + 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] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/recovery/RecoveryViewModel.kt new file mode 100644 index 0000000..df4b99c --- /dev/null +++ b/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()) + + 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() + val temperatureList = arrayListOf() + 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(), "")), + 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 + } + } + + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingAdapter.kt new file mode 100644 index 0000000..b21fe85 --- /dev/null +++ b/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() { + 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 + } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingFragment.kt new file mode 100644 index 0000000..ecf05cb --- /dev/null +++ b/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() { + 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 { + 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() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/setting/SettingViewModel.kt new file mode 100644 index 0000000..0278164 --- /dev/null +++ b/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() { +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateFragment.kt new file mode 100644 index 0000000..c0ada38 --- /dev/null +++ b/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() { + + 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) + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/home/state/StateViewModel.kt new file mode 100644 index 0000000..467d98b --- /dev/null +++ b/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()) + + 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>, + sleepData: SleepState? + ): List { + val today = todayCalendar() + val viewList = arrayListOf() + val hrEntry = arrayListOf() + 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 + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/launcher/LauncherActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/launcher/LauncherActivity.kt new file mode 100644 index 0000000..b49aa1d --- /dev/null +++ b/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() { + + override fun setLayout(): Int { + return R.layout.activity_launcher + } + + override fun setViewModel(): Class { + return BaseViewModel::class.java + } + + override fun bind() { + mBinding.btUse.setOnClickListener { + LoginActivity.start(this) + finish() + } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/login/LoginActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/login/LoginActivity.kt new file mode 100644 index 0000000..23b7ffe --- /dev/null +++ b/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() { + 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 { + return BaseViewModel::class.java + } + + override fun bind() { + mBinding.btLogin.setOnClickListener { + ConnectTipActivity.start(this) + finish() + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanActivity.kt new file mode 100644 index 0000000..7348139 --- /dev/null +++ b/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() { + override fun setLayout(): Int { + return R.layout.activity_plan + } + + override fun setViewModel(): Class { + 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)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/plan/PlanViewModel.kt new file mode 100644 index 0000000..d1fa029 --- /dev/null +++ b/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() { +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceActivity.kt new file mode 100644 index 0000000..d65cec2 --- /dev/null +++ b/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() { + + 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 { + 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() + } + } + } + +} + diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/PreferenceViewModel.kt new file mode 100644 index 0000000..51a6246 --- /dev/null +++ b/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()) + val weightList = arrayListOf().apply { + for (i in 1..100) { + add("$i kg") + } + } + + val targetList = MutableStateFlow(emptyList()) + val promoteList = MutableStateFlow(emptyList()) + 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 -> {} + } + } + } + +} + diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeAdapter.kt new file mode 100644 index 0000000..f52f199 --- /dev/null +++ b/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() { + + 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]) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/LikeFragment.kt new file mode 100644 index 0000000..e93318a --- /dev/null +++ b/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() { + 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.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 + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/PromoteFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/PromoteFragment.kt new file mode 100644 index 0000000..0619dc6 --- /dev/null +++ b/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() { + 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()) + + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindFragment.kt new file mode 100644 index 0000000..3c6368b --- /dev/null +++ b/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() { + 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] + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/RemindViewModel.kt new file mode 100644 index 0000000..436dadf --- /dev/null +++ b/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() { +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/SelectAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/SelectAdapter.kt new file mode 100644 index 0000000..54cc562 --- /dev/null +++ b/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() { + + private var selectedPosition = -1 + private val selectMap = HashMap() + + + 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, 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) + } + } + + } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/TargetFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/TargetFragment.kt new file mode 100644 index 0000000..230dead --- /dev/null +++ b/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() { + 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()) + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/WeightFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/preference/fragment/WeightFragment.kt new file mode 100644 index 0000000..33c14c8 --- /dev/null +++ b/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() { + 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()) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/recovery/HrvAssessmentActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/recovery/HrvAssessmentActivity.kt new file mode 100644 index 0000000..f30d28a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/recovery/HrvAssessmentActivity.kt @@ -0,0 +1,125 @@ +package com.whitefish.app.feature.recovery + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle +import androidx.room.DeleteTable +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.ValueFormatter +import com.whitefish.app.BaseActivity +import com.whitefish.app.BaseViewModel +import com.whitefish.app.R +import com.whitefish.app.databinding.ActivityHrvAssessmentBinding +import com.whitefish.app.ext.collectWith +import lib.linktop.nexring.api.IntData + +class HrvAssessmentActivity : BaseActivity() { + + companion object { + fun start(context: Context){ + val intent = Intent(context,HrvAssessmentActivity::class.java) + context.startActivity(intent) + } + } + + + override fun setLayout(): Int { + return R.layout.activity_hrv_assessment + } + + override fun setViewModel(): Class { + return HrvAssessmentViewModel::class.java + } + + override fun bind() { + + setupUI() + setupChart() + + mViewModel.getData() + + mViewModel.viewStat.collectWith(this,Lifecycle.State.RESUMED) { + setupDummyData(it.hrvList) + + // 设置恢复性睡眠数据 + mBinding.sleepRecoveryState.apply { + sleepPercentage.text = "0" + sleepPercentageAvg.text = "近30天平均0%" + sleepTotal.text = "0" + sleepTotalAvg.text = "近30天平均0分钟" + } + } + } + + private fun setupUI() { + // 设置标题栏和导航栏 + mBinding.back.setOnClickListener { + finish() + } + } + + private fun setupChart() { + mBinding.hrvChart.apply { + description.isEnabled = false + legend.isEnabled = false + setTouchEnabled(false) + setDrawGridBackground(false) + setDrawBorders(false) + + // X轴设置 + xAxis.apply { + position = XAxis.XAxisPosition.BOTTOM + setDrawGridLines(false) + textColor = Color.WHITE + valueFormatter = object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + return when (value.toInt()) { + 0 -> "00:00" + 12 -> "12:00" + 24 -> "24:00" + else -> "" + } + } + } + textColor = Color.parseColor("#80FFFFFF") + } + + // 左Y轴设置 + axisLeft.apply { + setDrawGridLines(true) + gridColor = Color.parseColor("#20FFFFFF") + textColor = Color.parseColor("#80FFFFFF") + axisMinimum = 0f + axisMaximum = 150f + setLabelCount(4, true) + } + + // 右Y轴禁用 + axisRight.isEnabled = false + } + } + + private fun setupDummyData(data:List) { + val entries = arrayListOf().apply { + data.mapIndexed { index, intData -> this.add(Entry(index.toFloat(),intData.value.toFloat())) } + } + + + val dataSet = LineDataSet(entries, "HRV").apply { + color = Color.WHITE + lineWidth = 2f + setDrawCircles(false) + setDrawValues(false) + mode = LineDataSet.Mode.CUBIC_BEZIER + } + + mBinding.hrvChart.data = LineData(dataSet) + mBinding.hrvChart.invalidate() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/recovery/HrvAssessmentViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/recovery/HrvAssessmentViewModel.kt new file mode 100644 index 0000000..e3fc9b9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/recovery/HrvAssessmentViewModel.kt @@ -0,0 +1,35 @@ +package com.whitefish.app.feature.recovery + +import androidx.lifecycle.viewModelScope +import com.orhanobut.logger.Logger +import com.whitefish.app.BaseViewModel +import com.whitefish.app.bean.SleepState +import com.whitefish.app.bean.toState +import com.whitefish.app.constants.Constants +import com.whitefish.app.device.DeviceDataProvider +import com.whitefish.app.device.DeviceManager +import com.whitefish.app.utils.getString +import com.whitefish.app.utils.todayCalendar +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import lib.linktop.nexring.api.IntData + +class HrvAssessmentViewModel:BaseViewModel() { + private val address = getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS,"") + private val _viewState = MutableStateFlow(HrvView(1, emptyList(), SleepState())) + val viewStat = _viewState.asStateFlow() + + fun getData(){ + viewModelScope.launch { + val dayCount = DeviceDataProvider.INSTANCE.getDayCount() + val hrvToday = DeviceDataProvider.INSTANCE.getHrvList(todayCalendar().timeInMillis,System.currentTimeMillis())?.second + val sleepData = DeviceDataProvider.INSTANCE.getSleepData(todayCalendar()) + Logger.i("dayCount:${dayCount} hrvList:${hrvToday}") + val hrvView = HrvView(dayCount?:0,hrvToday?: emptyList(),sleepData?.toState()?:SleepState()) + _viewState.emit(hrvView) + } + } +} +data class HrvView(val dayCount:Int,val hrvList:List,val sleepState:SleepState) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningActivity.kt new file mode 100644 index 0000000..6019cb5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningActivity.kt @@ -0,0 +1,60 @@ +package com.whitefish.app.feature.running + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.Lifecycle +import com.whitefish.app.BaseActivity +import com.whitefish.app.R +import com.whitefish.app.dao.bean.WorkoutType +import com.whitefish.app.databinding.ActivityRunningBinding +import com.whitefish.app.ext.collectWith +import com.whitefish.app.feature.home.exercise.DialogExerciseViewModel +import com.whitefish.app.utils.toSpannableTime + +class RunningActivity:BaseActivity() { + + override fun setLayout(): Int { + return R.layout.activity_running + } + + override fun setViewModel(): Class { + return RunningViewModel::class.java + } + + override fun bind() { + val duration = intent.getIntExtra("duration",5) + val type = intent.getStringExtra("type") + val target = intent.getIntExtra("target", DialogExerciseViewModel.TARGET_TYPE_FREE) + mViewModel.startWorkout(duration, WorkoutType.valueOf(type.toString()),target) + mViewModel.workoutTime.collectWith(this, Lifecycle.State.CREATED){ + mBinding.tvTime.text = it.toSpannableTime(this,false) + } + mBinding.back.setOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + mBinding.btnPause.setOnClickListener { + if (mViewModel.isWorkoutActive){ + mViewModel.endWorkout(true) + }else{ + val continueTime = duration - (mViewModel.workoutTime.value) / 60 + mViewModel.startWorkout(continueTime.toInt(),mViewModel.workoutType,mViewModel.target) + } + } + } + + companion object{ + fun start(context: Context, duration:Int, type: WorkoutType, target: Int){ + val intent = Intent(context,RunningActivity::class.java) + intent.putExtra("duration",duration) + intent.putExtra("type",type.name) + intent.putExtra("target",target) + context.startActivity(intent) + } + } + + override fun onStop() { + super.onStop() + mViewModel.stopWorkout() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningHintActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningHintActivity.kt new file mode 100644 index 0000000..d3a8470 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningHintActivity.kt @@ -0,0 +1,19 @@ +package com.whitefish.app.feature.running + +import com.whitefish.app.BaseActivity +import com.whitefish.app.R +import com.whitefish.app.databinding.ActivityRunningHintBinding + +class RunningHintActivity:BaseActivity() { + override fun setLayout(): Int { + return R.layout.activity_running_hint + } + + override fun setViewModel(): Class { + return RunningHintViewModel::class.java + } + + override fun bind() { + + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningHintViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningHintViewModel.kt new file mode 100644 index 0000000..12f2ebe --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningHintViewModel.kt @@ -0,0 +1,6 @@ +package com.whitefish.app.feature.running + +import com.whitefish.app.BaseViewModel + +class RunningHintViewModel:BaseViewModel(){ +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningViewModel.kt new file mode 100644 index 0000000..7f13bdd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/running/RunningViewModel.kt @@ -0,0 +1,142 @@ +package com.whitefish.app.feature.running + +import com.orhanobut.logger.Logger +import com.whitefish.app.BaseViewModel +import com.whitefish.app.dao.bean.WorkoutDbData +import com.whitefish.app.dao.bean.WorkoutType +import com.whitefish.app.constants.Constants +import com.whitefish.app.db.AppDatabase +import com.whitefish.app.feature.home.exercise.DialogExerciseViewModel.Companion.TARGET_TYPE_FREE +import com.whitefish.app.utils.getString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import lib.linktop.nexring.api.NexRingManager +import java.util.Timer +import java.util.TimerTask + +class RunningViewModel() : BaseViewModel() { + companion object { + const val INTERVAL = 10 + const val WORKOUT_TYPE_RUNNING = 1 + } + + private var startTs = 0L + private var endTs = 0L + private var timer: Timer? = null + var isWorkoutActive = false + private set + private val _workoutTime = MutableStateFlow(0L) + val workoutTime = _workoutTime.asStateFlow() + + private val database = AppDatabase.getDatabase() + private val workoutDao = database.workoutDao() + var target = TARGET_TYPE_FREE + private set + var workoutType = WorkoutType.WORKOUT_TYPE_OUTDOOR_RUNNING + private set + + private fun startTimer() { + timer?.cancel() + timer = Timer() + scheduleNextTask() + } + + private fun scheduleNextTask() { + if (!isWorkoutActive) return + + timer?.schedule(object : TimerTask() { + override fun run() { + if (isWorkoutActive) { + _workoutTime.value += 1 + scheduleNextTask() + } + } + }, 1000L) + } + + private fun stopTimer() { + timer?.cancel() + timer = null + } + + fun startWorkout(duration: Int, type: WorkoutType, target: Int) { + NexRingManager.get().healthApi().startWorkoutMode(INTERVAL, duration, true) { + Logger.i("workout task result is $it") + if (it == 0 || it == 4) { + //in workout mode + startTs = System.currentTimeMillis() + isWorkoutActive = true + this.workoutType = type + this.target = target + startTimer() + } + } + } + + fun stopWorkout() { + stopTimer() + endWorkout(false) + } + + fun endWorkout(pause: Boolean) { + isWorkoutActive = false + stopTimer() + endTs = System.currentTimeMillis() + NexRingManager.get().healthApi().endWorkoutMode { + Logger.i("workout end $it") + if (!pause) { + CoroutineScope(Dispatchers.IO).launch { + saveWorkoutData() + NexRingManager.get().sleepApi().syncDataFromDev() + } + } + } + } + + suspend fun saveWorkoutData() { + if (startTs != 0L) { + val workout = WorkoutDbData( + startTs = startTs, + endTs = System.currentTimeMillis(), + date = startTs, + btMac = getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, ""), + type = workoutType.ordinal, + target = target + ) + Logger.i("save workout:${workout}") + workoutDao.insert(workout) + } + +// viewModelScope.launch(Dispatchers.IO) { + +// try { +// val result = suspendCoroutine>?> { continuation -> +// NexRingManager.get().sleepApi().getWorkoutData( +// getString(Constants.SP_KEY_BOUND_DEVICE_ADDRESS, ""), startTs, +// endTs +// ) { result -> +// continuation.resume(result) +// } +// } +// +// Logger.i( +// "get workout data:${ +// getString( +// Constants.SP_KEY_BOUND_DEVICE_ADDRESS, +// "" +// ) +// }:${startTs} --> ${endTs}: ${result}" +// ) +// +// result?.let { (statistics, workoutDataList) -> +// } +// } catch (e: Exception) { +// Logger.e("Error saving workout data: ${e.message}") +// } +// } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepActivity.kt new file mode 100644 index 0000000..e7900b4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepActivity.kt @@ -0,0 +1,20 @@ +package com.whitefish.app.feature.sleep + +import com.whitefish.app.BaseActivity +import com.whitefish.app.R +import com.whitefish.app.databinding.ActivitySleepBinding + +class SleepActivity : BaseActivity() { + override fun setLayout(): Int { + return R.layout.activity_sleep + } + + override fun setViewModel(): Class { + return SleepViewModel::class.java + } + + override fun bind() { + } + + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailActivity.kt new file mode 100644 index 0000000..73e2643 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailActivity.kt @@ -0,0 +1,78 @@ +package com.whitefish.app.feature.sleep + +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.whitefish.app.BaseActivity +import com.whitefish.app.R +import com.whitefish.app.databinding.ActivitySleepDetailBinding +import com.whitefish.app.ext.collectWith +import com.whitefish.app.utils.todayCalendar +import java.util.Calendar + +class SleepDetailActivity : BaseActivity() { + companion object { + fun start(context: Context) { + val intent = Intent(context, SleepDetailActivity::class.java) + context.startActivity(intent) + } + } + + private val adapter = SleepDetailAdapter() + + override fun setLayout(): Int { + return R.layout.activity_sleep_detail + } + + override fun setViewModel(): Class { + return SleepDetailViewModel::class.java + } + + override fun bind() { + mBinding.recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + outRect.bottom = 13 + } + }) + mBinding.recyclerView.adapter = adapter + mBinding.recyclerView.layoutManager = LinearLayoutManager(this) + + mBinding.pastDay.setOnClickListener { + mViewModel.goToPastDay() + } + + mBinding.nextDay.setOnClickListener { + mViewModel.goToNextDay() + } + + mViewModel.viewState.collectWith(this, Lifecycle.State.RESUMED) { + if (it.totalDay > 0) { + adapter.setData(it.data) + mBinding.pastDay.isEnabled = + todayCalendar()[Calendar.DAY_OF_YEAR] - it.selectedDate[Calendar.DAY_OF_YEAR] < it.totalDay + } else { + mBinding.pastDay.isEnabled = false + } + + mBinding.date.text = if (it.selectedDate != todayCalendar()) { + mBinding.nextDay.isEnabled = true + "${it.selectedDate.get(Calendar.MONTH) + 1}月${it.selectedDate[Calendar.DAY_OF_MONTH]}日" + } else { + mBinding.nextDay.isEnabled = false + "今日" + } + } + } + + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailAdapter.kt new file mode 100644 index 0000000..3c9edbc --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailAdapter.kt @@ -0,0 +1,156 @@ +package com.whitefish.app.feature.sleep + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.whitefish.app.BaseAdapter +import com.whitefish.app.R +import com.whitefish.app.bean.AnimalType +import com.whitefish.app.bean.BiologicalClock +import com.whitefish.app.bean.RecoverySleep +import com.whitefish.app.bean.SleepState +import com.whitefish.app.databinding.CardAnimalTypeBinding +import com.whitefish.app.databinding.CardBiologicalClockBinding +import com.whitefish.app.databinding.CardSleepDetailBinding +import com.whitefish.app.databinding.CardSleepRecoveryStateBinding +import com.whitefish.app.utils.SpannableTimeFormatter +import com.whitefish.app.utils.TimeFormatter + +class SleepDetailAdapter : BaseAdapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + R.layout.card_sleep_detail -> { + SleepStateViewHolder( + DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.card_sleep_detail, + parent, + false + ) + ) + } + + + R.layout.card_animal_type -> { + CardAnimalTypeViewHolder( + DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.card_animal_type, + parent, + false + ) + ) + } + + R.layout.card_sleep_recovery_state -> { + CardRecoverySleepViewHolder( + DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.card_sleep_recovery_state, + parent, + false + ) + ) + } + + else -> { + CardBiologicalClockViewHolder( + DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.card_biological_clock, + parent, + false + ) + ) + } + } + } + + override fun getItemViewType(position: Int): Int { + + return when (data[position]) { + is SleepState -> { + R.layout.card_sleep_detail + } + + is RecoverySleep -> { + R.layout.card_sleep_recovery_state + } + + is AnimalType -> { + R.layout.card_animal_type + } + + else -> { + R.layout.card_biological_clock + } + } + + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = data[position]) { + is SleepState -> { + (holder as SleepStateViewHolder).bind(item) + } + + is RecoverySleep -> { + (holder as CardRecoverySleepViewHolder).bind(item) + } + + is AnimalType -> { + (holder as CardAnimalTypeViewHolder).bind(item) + } + + else -> { + (holder as CardBiologicalClockViewHolder).bind(item as BiologicalClock) + } + } + } + + class SleepStateViewHolder(val binding: CardSleepDetailBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(data: SleepState) { + binding.sleepState.setSleepData(data.sleepChartData) + binding.sleepTime.text = SpannableTimeFormatter.formatSpannableChinese(binding.root.context,(data.totalTime / 1000), numberTextSize = 24, unitTextSize = 12) + binding.sleepScore.text = data.score + binding.rating.rating = data.rating + binding.deepSleep.text = data.deepSleepTime + binding.lightSleep.text = data.lightSleepTime + binding.remSleep.text = data.remSleepTime + + } + } + + class CardAnimalTypeViewHolder(val binding: CardAnimalTypeBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(data: AnimalType) { + binding.animalType.text = data.animalName + binding.lastUpdate.text = data.lastUpdate + binding.enter.setOnClickListener { + SleepTypeActivity.start(binding.root.context) + } + Glide.with(binding.root.context).load(data.coverUrl).into(binding.animalImg) + } + } + + + class CardRecoverySleepViewHolder(val binding: CardSleepRecoveryStateBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(data: RecoverySleep) { + binding.sleepPercentage.text = data.percentage + binding.sleepPercentageAvg.text = data.percentageAvg + binding.sleepTotal.text = data.totalTime + binding.sleepTotalAvg.text = data.totalTimeAvg + } + } + + class CardBiologicalClockViewHolder(val binding: CardBiologicalClockBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(data: BiologicalClock) { + binding.biologicalTip.text = data.tip + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailViewModel.kt new file mode 100644 index 0000000..586830e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepDetailViewModel.kt @@ -0,0 +1,71 @@ +package com.whitefish.app.feature.sleep + +import androidx.lifecycle.viewModelScope +import com.whitefish.app.BaseViewModel +import com.whitefish.app.R +import com.whitefish.app.bean.AnimalType +import com.whitefish.app.bean.BiologicalClock +import com.whitefish.app.bean.RecoverySleep +import com.whitefish.app.bean.SleepData +import com.whitefish.app.bean.toState +import com.whitefish.app.device.DeviceDataProvider +import com.whitefish.app.utils.getPastDayCalendar +import com.whitefish.app.utils.todayCalendar +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.Calendar +import kotlin.collections.arrayListOf + +class SleepDetailViewModel : BaseViewModel() { + + val viewState = MutableStateFlow(ViewState()) + + val selectedDate = MutableStateFlow(todayCalendar()) + + init { + viewModelScope.launch { + selectedDate.collectLatest { + val sleepData = DeviceDataProvider.INSTANCE.getSleepData(it) + val recoveryState = RecoverySleep("0%", "0%", "0", "0分钟") + val totalDay = DeviceDataProvider.INSTANCE.getDayCount() + val clock = + BiologicalClock("你通常的睡眠中心时间比你认为最佳的睡眠时间晚了0分钟。通过调整时间表,使睡眠中心时间一致,您可以保持更多的活力。") + val ani = AnimalType("熊", R.drawable.ic_ani_beer, "上次更新 2024/08/02") + val list = arrayListOf() + sleepData?.let { sleep -> + list.add(sleep.toState()) + } + list.add(recoveryState) + list.add(clock) + list.add(ani) + viewState.value = ViewState(it,list,totalDay?:0) + + } + } + } + + fun goToPastDay(){ + val currentDate = selectedDate.value + val pastDate = Calendar.getInstance().apply { + time = currentDate.time + add(Calendar.DAY_OF_YEAR, -1) + } + selectedDate.value = pastDate + } + + fun goToNextDay(){ + val currentDate = selectedDate.value + val nextDate = Calendar.getInstance().apply { + time = currentDate.time + add(Calendar.DAY_OF_YEAR, 1) + } + selectedDate.value = nextDate + } + + data class ViewState( + val selectedDate:Calendar = todayCalendar(), + val data:List = emptyList(), + val totalDay:Int = 0, + ) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeActivity.kt new file mode 100644 index 0000000..f0fc202 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeActivity.kt @@ -0,0 +1,63 @@ +package com.whitefish.app.feature.sleep + +import android.content.Context +import android.content.Intent +import androidx.recyclerview.widget.GridLayoutManager +import com.whitefish.app.BaseActivity +import com.whitefish.app.R +import com.whitefish.app.databinding.ActivitySleepTypeBinding + +class SleepTypeActivity : BaseActivity() { + + companion object { + fun start(context: Context) { + val intent = Intent(context, SleepTypeActivity::class.java) + context.startActivity(intent) + } + } + + private val dateAdapter = SleepTypeDateAdapter() + + override fun setLayout(): Int { + return R.layout.activity_sleep_type + } + + override fun setViewModel(): Class { + return SleepTypeViewModel::class.java + } + + override fun bind() { + // 设置返回按钮 + mBinding.back.setOnClickListener { + finish() + } + + // 设置日期指示器RecyclerView + mBinding.dateRecyclerView.apply { + layoutManager = GridLayoutManager(this@SleepTypeActivity, 9) + adapter = dateAdapter + } + + // 设置了解更多按钮 + mBinding.learnMoreBtn.setOnClickListener { + // TODO: 跳转到更多动物类型页面 + } + + // 设置关闭按钮 + mBinding.closeBtn.setOnClickListener { + finish() + } + + // 观察数据变化 + mViewModel.initData() + + // 设置初始数据 + val dates = (1..9).map { SleepTypeDateItem(it, it == 5) } + dateAdapter.setData(dates) + + // 设置动物类型信息 + mBinding.animalType.text = mViewModel.getAnimalType() + mBinding.animalDescription.text = mViewModel.getAnimalDescription() + mBinding.dateRange.text = mViewModel.getCurrentDate() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeDateAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeDateAdapter.kt new file mode 100644 index 0000000..5576000 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeDateAdapter.kt @@ -0,0 +1,45 @@ +package com.whitefish.app.feature.sleep + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.whitefish.app.databinding.ItemSleepTypeDateBinding + +data class SleepTypeDateItem( + val day: Int, + val isSelected: Boolean +) + +class SleepTypeDateAdapter : RecyclerView.Adapter() { + + private var data: List = emptyList() + + fun setData(newData: List) { + data = newData + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemSleepTypeDateBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(data[position]) + } + + override fun getItemCount(): Int = data.size + + class ViewHolder(private val binding: ItemSleepTypeDateBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: SleepTypeDateItem) { + binding.dayText.text = item.day.toString() + binding.dayContainer.setBackgroundResource(com.whitefish.app.R.drawable.ic_sleep_type_date) + binding.dayText.setTextColor(binding.root.context.getColor(com.whitefish.app.R.color.white)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeViewModel.kt new file mode 100644 index 0000000..21b97dc --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepTypeViewModel.kt @@ -0,0 +1,26 @@ +package com.whitefish.app.feature.sleep + +import com.whitefish.app.BaseViewModel + +class SleepTypeViewModel : BaseViewModel() { + + fun initData() { + // TODO: 初始化睡眠类型数据 + // 获取用户的睡眠类型分析结果 + } + + fun getAnimalType(): String { + // TODO: 返回分析得出的动物类型 + return "熊" + } + + fun getAnimalDescription(): String { + // TODO: 返回动物类型的描述 + return "熊型睡眠人群占总人口的50%55%。\n这些人有良好的睡眠规律,\n作息遵循太阳周期。" + } + + fun getCurrentDate(): String { + // TODO: 返回当前显示的日期 + return "2024.7.27-2024.8.2" + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepViewModel.kt new file mode 100644 index 0000000..d96cb23 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/sleep/SleepViewModel.kt @@ -0,0 +1,6 @@ +package com.whitefish.app.feature.sleep + +import com.whitefish.app.BaseViewModel + +class SleepViewModel:BaseViewModel() { +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/HandednessFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/HandednessFragment.kt new file mode 100644 index 0000000..0c32921 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/HandednessFragment.kt @@ -0,0 +1,36 @@ +package com.whitefish.app.feature.userInfo + +import androidx.fragment.app.activityViewModels +import com.whitefish.app.BaseFragment +import com.whitefish.app.R +import com.whitefish.app.databinding.FragmentHandednessBinding +import com.whitefish.app.feature.preference.PreferenceActivity + +class HandednessFragment:BaseFragment() { + private val viewModel:UserInfoViewModel by activityViewModels() + override fun bind() { + + mBinding.left.setOnClickListener { + mBinding.ivLeft.setImageResource(R.drawable.handedness_selected_left) + mBinding.ivRight.setImageResource(R.drawable.block_hand_right) + } + + + mBinding.right.setOnClickListener { + mBinding.ivRight.setImageResource(R.drawable.handedness_selected_right) + mBinding.ivLeft.setImageResource(R.drawable.block_hand_left) + } + + mBinding.clDone.setOnClickListener { + PreferenceActivity.start(requireContext()) + } + } + + override fun setLayout(): Int { + return R.layout.fragment_handedness + } + + override fun setViewModel(): UserInfoViewModel { + return viewModel + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoActivity.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoActivity.kt new file mode 100644 index 0000000..1c79042 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoActivity.kt @@ -0,0 +1,55 @@ +package com.whitefish.app.feature.userInfo + +import android.content.Context +import android.content.Intent +import androidx.fragment.app.Fragment +import com.whitefish.app.BaseActivity +import com.whitefish.app.R +import com.whitefish.app.databinding.ActivityUserInfoBinding + +class UserInfoActivity : BaseActivity() { + companion object{ + fun start(context:Context){ + val intent = Intent(context,UserInfoActivity::class.java) + context.startActivity(intent) + } + } + override fun setLayout(): Int { + return R.layout.activity_user_info + } + + override fun setViewModel(): Class { + return UserInfoViewModel::class.java + } + + override fun bind() { + showFragment(ViewType.TYPE_USERINFO) + } + + fun showFragment(type: ViewType) { + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContent, obtainFragment(type)).commit() + } + + fun obtainFragment(type: ViewType): Fragment { + return when (type) { + ViewType.TYPE_USERINFO -> { + UserInfoFragment() + } + + ViewType.TYPE_WARE -> { + WearFragment() + } + + ViewType.TYPE_HANDEDNESS -> { + HandednessFragment() + } + } + } + + enum class ViewType { + TYPE_USERINFO, + TYPE_WARE, + TYPE_HANDEDNESS + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoFragment.kt new file mode 100644 index 0000000..a69523d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoFragment.kt @@ -0,0 +1,23 @@ +package com.whitefish.app.feature.userInfo + +import androidx.fragment.app.activityViewModels +import com.whitefish.app.BaseFragment +import com.whitefish.app.R +import com.whitefish.app.databinding.FragmentUserInfoBinding + +class UserInfoFragment:BaseFragment() { + private val viewModel:UserInfoViewModel by activityViewModels() + override fun bind() { + mBinding.clNext.setOnClickListener { + (requireActivity() as UserInfoActivity).showFragment(UserInfoActivity.ViewType.TYPE_WARE) + } + } + + override fun setLayout(): Int { + return R.layout.fragment_user_info + } + + override fun setViewModel(): UserInfoViewModel { + return viewModel + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoViewModel.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoViewModel.kt new file mode 100644 index 0000000..b678750 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/UserInfoViewModel.kt @@ -0,0 +1,10 @@ +package com.whitefish.app.feature.userInfo + +import com.whitefish.app.BaseViewModel +import com.whitefish.app.bean.UserInfo +import kotlinx.coroutines.flow.MutableStateFlow + +class UserInfoViewModel:BaseViewModel() { + val userInfoFlow = MutableStateFlow(UserInfo()) + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/WearFragment.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/WearFragment.kt new file mode 100644 index 0000000..bb5f834 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/feature/userInfo/WearFragment.kt @@ -0,0 +1,23 @@ +package com.whitefish.app.feature.userInfo + +import androidx.fragment.app.activityViewModels +import com.whitefish.app.BaseFragment +import com.whitefish.app.R +import com.whitefish.app.databinding.FragmentWearBinding + +class WearFragment:BaseFragment() { + private val viewModel:UserInfoViewModel by activityViewModels() + override fun bind() { + mBinding.clNext.setOnClickListener { + (requireActivity() as UserInfoActivity).showFragment(UserInfoActivity.ViewType.TYPE_HANDEDNESS) + } + } + + override fun setLayout(): Int { + return R.layout.fragment_wear + } + + override fun setViewModel(): UserInfoViewModel { + return viewModel + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/ActivityLifecycleCb.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/ActivityLifecycleCb.kt new file mode 100644 index 0000000..8888cc9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/ActivityLifecycleCb.kt @@ -0,0 +1,58 @@ +package com.whitefish.app.utils + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.util.Log + +class ActivityLifecycleCb : Application.ActivityLifecycleCallbacks { + + /** + * 如果是从后台打开APP的,此标志意味着可以从设备拉数据 + * + * + var readDataFromDevice: Boolean = false + */ + + private var flag = 0 + val activities = ArrayList() + + /** + * 判断APP是否在前台运行 + * */ + val isAppForeground: Boolean get() = flag > 0 + var backgroundFlag = true + + val currAct: Activity? + get() = if (activities.isNotEmpty()) activities[activities.size - 1] else null + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + activities.add(activity) + } + + override fun onActivityStarted(activity: Activity) { + flag++ + Log.i("ActivityLifecycleCb", "onActivityStarted - flag:$flag") + } + + override fun onActivityResumed(activity: Activity) { + } + + override fun onActivityPaused(activity: Activity) { + } + + override fun onActivityStopped(activity: Activity) { + flag-- + if (!isAppForeground) { + backgroundFlag = true + } + Log.i("ActivityLifecycleCb", "onActivityStopped - flag:$flag") + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + } + + override fun onActivityDestroyed(activity: Activity) { + activities.remove(activity) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/HandlerHelper.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/HandlerHelper.kt new file mode 100644 index 0000000..6cd9009 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/HandlerHelper.kt @@ -0,0 +1,18 @@ +package com.whitefish.app.utils + +import android.os.Handler +import android.os.Looper + +private val uiHandler = Handler(Looper.getMainLooper()) + +fun postDelay(r: Runnable, delay: Long) = uiHandler.postDelayed(r, delay) + +fun postDelay(r: Runnable) = postDelay(r, 100L) + +fun post(r: Runnable) = uiHandler.post(r) + +fun Runnable.handlerPost() = post(this) + +fun Runnable.handlerPostDelay(delay: Long) = postDelay(this, delay) + +fun Runnable.handlerRemove() = uiHandler.removeCallbacks(this) \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/RefreshEmit.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/RefreshEmit.kt new file mode 100644 index 0000000..e574e83 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/RefreshEmit.kt @@ -0,0 +1,13 @@ +package com.whitefish.app.utils + +import kotlin.random.Random + +class RefreshEmit(val code:Int = 0) { + override fun equals(other: Any?): Boolean { + return false + } + + override fun hashCode(): Int { + return Random.nextInt() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/SPUtils.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/SPUtils.kt new file mode 100644 index 0000000..87f758c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/SPUtils.kt @@ -0,0 +1,13 @@ +package com.whitefish.app.utils + +import android.content.Context +import com.whitefish.app.Application +import com.whitefish.app.constants.Constants.SP_NAME + +fun getString(key: String,def: String): String{ + return Application.INSTANTS!!.getSharedPreferences(SP_NAME,Context.MODE_PRIVATE).getString(key,def).toString() +} + +fun putString(key: String,value: String){ + Application.INSTANTS!!.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE).edit().putString(key,value).apply() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TextSizeUtils.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TextSizeUtils.kt new file mode 100644 index 0000000..ada0f84 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TextSizeUtils.kt @@ -0,0 +1,61 @@ +package com.whitefish.app.utils + +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.AbsoluteSizeSpan +import android.text.style.StyleSpan +import android.graphics.Typeface +import android.widget.TextView + +/** + * 创建包含不同大小文本的SpannableStringBuilder + * @param text 文本内容 + * @param textSize 文本大小(单位:sp) + * @param isBold 是否使用粗体 + * @return SpannableStringBuilder对象 + */ +fun createSpannableText(text: String, textSize: Int, isBold: Boolean = false): SpannableStringBuilder { + val spannableString = SpannableString(text) + spannableString.setSpan( + AbsoluteSizeSpan(textSize, true), + 0, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + if (isBold) { + spannableString.setSpan( + StyleSpan(Typeface.BOLD), + 0, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + return SpannableStringBuilder(spannableString) +} + +/** + * 向SpannableStringBuilder追加文本 + * @param text 要追加的文本 + * @param size 文本大小(单位:sp) + * @param isBold 是否使用粗体 + * @return SpannableStringBuilder对象 + */ +fun SpannableStringBuilder.append(text: String, size: Int, isBold: Boolean = false): SpannableStringBuilder { + val spannableString = SpannableString(text) + spannableString.setSpan( + AbsoluteSizeSpan(size, true), + 0, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + if (isBold) { + spannableString.setSpan( + StyleSpan(Typeface.BOLD), + 0, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + return append(spannableString) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TimeFormatter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TimeFormatter.kt new file mode 100644 index 0000000..637c888 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TimeFormatter.kt @@ -0,0 +1,284 @@ +package com.whitefish.app.utils + +/** + * 时间格式化工具类,将秒数转换为小时、分钟、秒的格式 + */ +object TimeFormatter { + + /** + * 将秒数转换为"XXhXXmXXs"格式的字符串 + * 例如:3725秒 -> "1h2m5s" + * + * @param seconds 总秒数 + * @param showZeroUnits 是否显示为零的单位(默认为false) + * @return 格式化后的时间字符串 + */ + fun formatCompact(seconds: Long, showZeroUnits: Boolean = false): String { + if (seconds < 0) return "0s" + + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + + val result = StringBuilder() + + if (hours > 0 || showZeroUnits) { + result.append("${hours}h") + } + + if (minutes > 0 || showZeroUnits) { + result.append("${minutes}m") + } + + if (remainingSeconds > 0 || showZeroUnits || (hours == 0L && minutes == 0L)) { + result.append("${remainingSeconds}s") + } + + return result.toString() + } + + /** + * 将秒数转换为"XX h XX m XX s"格式的字符串(带空格) + * 例如:3725秒 -> "1 h 2 m 5 s" + * + * @param seconds 总秒数 + * @param showZeroUnits 是否显示为零的单位(默认为false) + * @return 格式化后的时间字符串 + */ + fun formatWithSpaces(seconds: Long, showZeroUnits: Boolean = false): String { + if (seconds < 0) return "0 s" + + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + + val result = StringBuilder() + + if (hours > 0 || showZeroUnits) { + result.append("$hours h ") + } + + if (minutes > 0 || showZeroUnits) { + result.append("$minutes m ") + } + + if (remainingSeconds > 0 || showZeroUnits || (hours == 0L && minutes == 0L)) { + result.append("$remainingSeconds s") + } else { + // 移除最后一个空格(如果有的话) + if (result.isNotEmpty() && result.last() == ' ') { + result.deleteCharAt(result.length - 1) + } + } + + return result.toString() + } + + /** + * 将秒数转换为"XX小时XX分钟XX秒"格式的字符串(中文单位) + * 例如:3725秒 -> "1小时2分钟5秒" + * + * @param seconds 总秒数 + * @param showZeroUnits 是否显示为零的单位(默认为false) + * @return 格式化后的时间字符串 + */ + fun formatChinese(seconds: Long, showZeroUnits: Boolean = false): String { + if (seconds < 0) return "0秒" + + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + + val result = StringBuilder() + + if (hours > 0 || showZeroUnits) { + result.append("${hours}小时") + } + + if (minutes > 0 || showZeroUnits) { + result.append("${minutes}分钟") + } + + if (remainingSeconds > 0 || showZeroUnits || (hours == 0L && minutes == 0L)) { + result.append("${remainingSeconds}秒") + } + + return result.toString() + } + + /** + * 将秒数转换为可配置格式的字符串 + * + * @param seconds 总秒数 + * @param hourSuffix 小时单位后缀 + * @param minuteSuffix 分钟单位后缀 + * @param secondSuffix 秒单位后缀 + * @param separator 单位之间的分隔符 + * @param showZeroUnits 是否显示为零的单位 + * @return 格式化后的时间字符串 + */ + fun formatCustom( + seconds: Long, + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + separator: String = "", + showZeroUnits: Boolean = false + ): String { + if (seconds < 0) return "0$secondSuffix" + + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + + val result = StringBuilder() + var needSeparator = false + + if (hours > 0 || showZeroUnits) { + result.append("$hours$hourSuffix") + needSeparator = true + } + + if (minutes > 0 || showZeroUnits) { + if (needSeparator) result.append(separator) + result.append("$minutes$minuteSuffix") + needSeparator = true + } + + if (remainingSeconds > 0 || showZeroUnits || (hours == 0L && minutes == 0L)) { + if (needSeparator) result.append(separator) + result.append("$remainingSeconds$secondSuffix") + } + + return result.toString() + } + + /** + * 将秒数转换为数字组件格式,返回包含小时、分钟、秒的数据类 + * 用于需要单独显示或处理各个时间单位的场景 + * + * @param seconds 总秒数 + * @return TimeComponents 包含小时、分钟、秒的数据类 + */ + fun toComponents(seconds: Long): TimeComponents { + if (seconds < 0) return TimeComponents(0, 0, 0) + + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + + return TimeComponents(hours.toInt(), minutes.toInt(), remainingSeconds.toInt()) + } + + /** + * 时间组件数据类,包含小时、分钟、秒 + */ + data class TimeComponents(val hours: Int, val minutes: Int, val seconds: Int) { + /** + * 转换为紧凑格式字符串 + */ + fun toCompactString(showZeroUnits: Boolean = false): String { + return formatCustom( + (hours * 3600 + minutes * 60 + seconds).toLong(), + showZeroUnits = showZeroUnits + ) + } + } +} + + +/** + * 将秒数转换为"XXhXXmXXs"格式 + */ +fun Int.toTimeString(showZeroUnits: Boolean = false): String { + return TimeFormatter.formatCompact(this.toLong(), showZeroUnits) +} + +/** + * 将秒数转换为"XXhXXmXXs"格式 + */ +fun Long.toTimeString(showZeroUnits: Boolean = false): String { + return TimeFormatter.formatCompact(this, showZeroUnits) +} + +/** + * 将秒数转换为"XX h XX m XX s"格式(带空格) + */ +fun Int.toTimeStringWithSpaces(showZeroUnits: Boolean = false): String { + return TimeFormatter.formatWithSpaces(this.toLong(), showZeroUnits) +} + +/** + * 将秒数转换为"XX h XX m XX s"格式(带空格) + */ +fun Long.toTimeStringWithSpaces(showZeroUnits: Boolean = false): String { + return TimeFormatter.formatWithSpaces(this, showZeroUnits) +} + +/** + * 将秒数转换为"XX小时XX分钟XX秒"格式(中文) + */ +fun Int.toChineseTimeString(showZeroUnits: Boolean = false): String { + return TimeFormatter.formatChinese(this.toLong(), showZeroUnits) +} + +/** + * 将秒数转换为"XX小时XX分钟XX秒"格式(中文) + */ +fun Long.toChineseTimeString(showZeroUnits: Boolean = false): String { + return TimeFormatter.formatChinese(this, showZeroUnits) +} + +/** + * 将秒数转换为自定义格式 + */ +fun Int.toCustomTimeString( + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + separator: String = "", + showZeroUnits: Boolean = false +): String { + return TimeFormatter.formatCustom( + this.toLong(), + hourSuffix, + minuteSuffix, + secondSuffix, + separator, + showZeroUnits + ) +} + +/** + * 将秒数转换为自定义格式 + */ +fun Long.toCustomTimeString( + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + separator: String = "", + showZeroUnits: Boolean = false +): String { + return TimeFormatter.formatCustom( + this, + hourSuffix, + minuteSuffix, + secondSuffix, + separator, + showZeroUnits + ) +} + +/** + * 将秒数转换为时间组件 + */ +fun Int.toTimeComponents(): TimeFormatter.TimeComponents { + return TimeFormatter.toComponents(this.toLong()) +} + +/** + * 将秒数转换为时间组件 + */ +fun Long.toTimeComponents(): TimeFormatter.TimeComponents { + return TimeFormatter.toComponents(this) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TimeSpannableTextUtils.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TimeSpannableTextUtils.kt new file mode 100644 index 0000000..7d59b4f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/TimeSpannableTextUtils.kt @@ -0,0 +1,460 @@ +package com.whitefish.app.utils + +import android.content.Context +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.AbsoluteSizeSpan +import android.text.style.StyleSpan +import android.widget.TextView + +/** + * 时间格式化工具类的扩展,结合SpannableTextBuilder显示格式化时间 + * 数字使用50sp,单位使用24sp,全部文本使用粗体 + */ +object SpannableTimeFormatter { + + const val DEFAULT_NUMBER_TEXT_SIZE_SP = 50 + const val DEFAULT_UNIT_TEXT_SIZE_SP = 24 + + /** + * 将秒数转换为带有不同字体大小的SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + * + * @param context Context实例 + * @param seconds 总秒数 + * @param showZeroUnits 是否显示为零的单位(默认为false) + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + * @return 格式化后的SpannableStringBuilder + */ + fun formatSpannable( + context: Context, + seconds: Long, + showZeroUnits: Boolean = false, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ): SpannableStringBuilder { + if (seconds < 0) { + return createSpannableTime(context, "0", "s", numberTextSize, unitTextSize) + } + + val components = TimeFormatter.toComponents(seconds) + val builder = SpannableStringBuilder() + + // 添加小时部分 + if (components.hours > 0 || showZeroUnits) { + appendSpannableTime(context, builder, components.hours.toString(), "h", numberTextSize, unitTextSize) + } + + // 添加分钟部分 + if (components.minutes > 0 || showZeroUnits) { + appendSpannableTime(context, builder, components.minutes.toString(), "m", numberTextSize, unitTextSize) + } + + // 添加秒部分 + if (components.seconds > 0 || showZeroUnits || (components.hours == 0 && components.minutes == 0)) { + appendSpannableTime(context, builder, components.seconds.toString(), "s", numberTextSize, unitTextSize) + } + + return builder + } + + /** + * 将秒数转换为带有不同字体大小的SpannableStringBuilder(中文单位) + * 数字使用50sp,单位使用24sp,全部使用粗体 + * + * @param context Context实例 + * @param seconds 总秒数 + * @param showZeroUnits 是否显示为零的单位(默认为false) + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + * @return 格式化后的SpannableStringBuilder + */ + fun formatSpannableChinese( + context: Context, + seconds: Long, + showZeroUnits: Boolean = false, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ): SpannableStringBuilder { + if (seconds < 0) { + return createSpannableTime(context, "0", "秒", numberTextSize, unitTextSize) + } + + val components = TimeFormatter.toComponents(seconds) + val builder = SpannableStringBuilder() + + // 添加小时部分 + if (components.hours > 0 || showZeroUnits) { + appendSpannableTime(context, builder, components.hours.toString(), "小时", numberTextSize, unitTextSize) + } + + // 添加分钟部分 + if (components.minutes > 0 || showZeroUnits) { + appendSpannableTime(context, builder, components.minutes.toString(), "分钟", numberTextSize, unitTextSize) + } + + // 添加秒部分 + if (components.seconds > 0 || showZeroUnits || (components.hours == 0 && components.minutes == 0)) { + appendSpannableTime(context, builder, components.seconds.toString(), "秒", numberTextSize, unitTextSize) + } + + return builder + } + + /** + * 将秒数转换为自定义格式的SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + * + * @param context Context实例 + * @param seconds 总秒数 + * @param hourSuffix 小时单位后缀 + * @param minuteSuffix 分钟单位后缀 + * @param secondSuffix 秒单位后缀 + * @param showZeroUnits 是否显示为零的单位 + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + * @return 格式化后的SpannableStringBuilder + */ + fun formatSpannableCustom( + context: Context, + seconds: Long, + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + showZeroUnits: Boolean = false, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ): SpannableStringBuilder { + if (seconds < 0) { + return createSpannableTime(context, "0", secondSuffix, numberTextSize, unitTextSize) + } + + val components = TimeFormatter.toComponents(seconds) + val builder = SpannableStringBuilder() + + // 添加小时部分 + if (components.hours > 0 || showZeroUnits) { + appendSpannableTime(context, builder, components.hours.toString(), hourSuffix, numberTextSize, unitTextSize) + } + + // 添加分钟部分 + if (components.minutes > 0 || showZeroUnits) { + appendSpannableTime(context, builder, components.minutes.toString(), minuteSuffix, numberTextSize, unitTextSize) + } + + // 添加秒部分 + if (components.seconds > 0 || showZeroUnits || (components.hours == 0 && components.minutes == 0)) { + appendSpannableTime(context, builder, components.seconds.toString(), secondSuffix, numberTextSize, unitTextSize) + } + + return builder + } + + /** + * 创建单个时间单位的SpannableStringBuilder + * 数字部分使用50sp,单位部分使用24sp,全部使用粗体 + * + * @param context Context实例 + * @param number 数字部分 + * @param unit 单位部分 + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + * @return 格式化后的SpannableStringBuilder + */ + private fun createSpannableTime( + context: Context, + number: String, + unit: String, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ): SpannableStringBuilder { + val builder = SpannableStringBuilder() + appendSpannableTime(context, builder, number, unit, numberTextSize, unitTextSize) + return builder + } + + /** + * 向SpannableStringBuilder追加时间单位 + * 数字部分使用50sp,单位部分使用24sp,全部使用粗体 + * + * @param context Context实例 + * @param builder 目标SpannableStringBuilder + * @param number 数字部分 + * @param unit 单位部分 + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + */ + private fun appendSpannableTime( + context: Context, + builder: SpannableStringBuilder, + number: String, + unit: String, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ) { + // 添加数字部分 + val numberStart = builder.length + builder.append(number) + val numberEnd = builder.length + + // 设置数字部分样式:自定义大小,粗体 + builder.setSpan( + AbsoluteSizeSpan(numberTextSize, true), + numberStart, + numberEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + builder.setSpan( + StyleSpan(Typeface.BOLD), + numberStart, + numberEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + + // 添加单位部分 + val unitStart = builder.length + builder.append(unit) + val unitEnd = builder.length + + // 设置单位部分样式:自定义大小,粗体 + builder.setSpan( + AbsoluteSizeSpan(unitTextSize, true), + unitStart, + unitEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + builder.setSpan( + StyleSpan(Typeface.BOLD), + unitStart, + unitEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + /** + * 将格式化的时间直接设置到TextView + * + * @param textView 目标TextView + * @param seconds 总秒数 + * @param showZeroUnits 是否显示为零的单位 + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + */ + fun setFormattedTime( + textView: TextView, + seconds: Long, + showZeroUnits: Boolean = false, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ) { + textView.text = formatSpannable(textView.context, seconds, showZeroUnits, numberTextSize, unitTextSize) + } + + /** + * 将格式化的中文时间直接设置到TextView + * + * @param textView 目标TextView + * @param seconds 总秒数 + * @param showZeroUnits 是否显示为零的单位 + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + */ + fun setFormattedChineseTime( + textView: TextView, + seconds: Long, + showZeroUnits: Boolean = false, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ) { + textView.text = formatSpannableChinese(textView.context, seconds, showZeroUnits, numberTextSize, unitTextSize) + } + + /** + * 将自定义格式的时间直接设置到TextView + * + * @param textView 目标TextView + * @param seconds 总秒数 + * @param hourSuffix 小时单位后缀 + * @param minuteSuffix 分钟单位后缀 + * @param secondSuffix 秒单位后缀 + * @param showZeroUnits 是否显示为零的单位 + * @param numberTextSize 数字字体大小(默认为50sp) + * @param unitTextSize 单位字体大小(默认为24sp) + */ + fun setFormattedCustomTime( + textView: TextView, + seconds: Long, + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + showZeroUnits: Boolean = false, + numberTextSize: Int = DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = DEFAULT_UNIT_TEXT_SIZE_SP + ) { + textView.text = formatSpannableCustom( + textView.context, + seconds, + hourSuffix, + minuteSuffix, + secondSuffix, + showZeroUnits, + numberTextSize, + unitTextSize + ) + } +} + +/** + * 将秒数转换为带有不同字体大小的SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun Long.toSpannableTime( + context: android.content.Context, + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +): SpannableStringBuilder { + return SpannableTimeFormatter.formatSpannable(context, this, showZeroUnits, numberTextSize, unitTextSize) +} + +/** + * 将秒数转换为带有不同字体大小的SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun Int.toSpannableTime( + context: android.content.Context, + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +): SpannableStringBuilder { + return SpannableTimeFormatter.formatSpannable(context, this.toLong(), showZeroUnits, numberTextSize, unitTextSize) +} + +/** + * 将秒数转换为带有不同字体大小的中文格式SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun Long.toSpannableChineseTime( + context: android.content.Context, + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +): SpannableStringBuilder { + return SpannableTimeFormatter.formatSpannableChinese(context, this, showZeroUnits, numberTextSize, unitTextSize) +} + +/** + * 将秒数转换为带有不同字体大小的中文格式SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun Int.toSpannableChineseTime( + context: android.content.Context, + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +): SpannableStringBuilder { + return SpannableTimeFormatter.formatSpannableChinese(context, this.toLong(), showZeroUnits, numberTextSize, unitTextSize) +} + +/** + * 将秒数转换为带有不同字体大小的自定义格式SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun Long.toSpannableCustomTime( + context: android.content.Context, + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +): SpannableStringBuilder { + return SpannableTimeFormatter.formatSpannableCustom( + context, + this, + hourSuffix, + minuteSuffix, + secondSuffix, + showZeroUnits, + numberTextSize, + unitTextSize + ) +} + +/** + * 将秒数转换为带有不同字体大小的自定义格式SpannableStringBuilder + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun Int.toSpannableCustomTime( + context: android.content.Context, + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +): SpannableStringBuilder { + return SpannableTimeFormatter.formatSpannableCustom( + context, + this.toLong(), + hourSuffix, + minuteSuffix, + secondSuffix, + showZeroUnits, + numberTextSize, + unitTextSize + ) +} + +/** + * TextView扩展函数,直接设置格式化的时间显示 + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun TextView.setFormattedTime( + seconds: Long, + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +) { + SpannableTimeFormatter.setFormattedTime(this, seconds, showZeroUnits, numberTextSize, unitTextSize) +} + +/** + * TextView扩展函数,直接设置格式化的中文时间显示 + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun TextView.setFormattedChineseTime( + seconds: Long, + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +) { + SpannableTimeFormatter.setFormattedChineseTime(this, seconds, showZeroUnits, numberTextSize, unitTextSize) +} + +/** + * TextView扩展函数,直接设置格式化的自定义时间显示 + * 数字使用50sp,单位使用24sp,全部使用粗体 + */ +fun TextView.setFormattedCustomTime( + seconds: Long, + hourSuffix: String = "h", + minuteSuffix: String = "m", + secondSuffix: String = "s", + showZeroUnits: Boolean = false, + numberTextSize: Int = SpannableTimeFormatter.DEFAULT_NUMBER_TEXT_SIZE_SP, + unitTextSize: Int = SpannableTimeFormatter.DEFAULT_UNIT_TEXT_SIZE_SP +) { + SpannableTimeFormatter.setFormattedCustomTime( + this, + seconds, + hourSuffix, + minuteSuffix, + secondSuffix, + showZeroUnits, + numberTextSize, + unitTextSize + ) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/Utils.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/Utils.kt new file mode 100644 index 0000000..22644a1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/utils/Utils.kt @@ -0,0 +1,451 @@ +package com.whitefish.app.utils + +import android.annotation.SuppressLint +import android.app.Application +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.os.Process +import android.preference.PreferenceManager +import android.provider.Settings +import android.util.Log +import android.util.TypedValue +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.app.ActivityCompat +import androidx.core.content.FileProvider +import androidx.room.Room +import androidx.room.RoomDatabase +import com.whitefish.app.R +import lib.linktop.nexring.api.* +import java.io.File +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.system.exitProcess + +val DEBUG_ON: Boolean = true +const val TAG = "NexRingSdkApp" + +const val ONE_DAY_TS = 86400000L +const val ONE_HOUR_MINUTES = 60 + +@Throws(GeneralSecurityException::class, IOException::class) +fun Context.createDefaultSharedPreferences(): SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(this) + +@Throws(GeneralSecurityException::class, IOException::class) +fun Application.createSharePreference(name: String): SharedPreferences = + getSharedPreferences("${packageName}_${name}", Context.MODE_PRIVATE) + +fun Context.createRoom( + klass: Class, + name: String, +): RoomDatabase.Builder { + return Room.databaseBuilder(this, klass, name) +} + +fun loge(tag: String, msg: String) { + if (DEBUG_ON) { + Log.e(tag, msg) + } +} + +fun loge(tag: String, msg: String, e: Throwable) { + if (DEBUG_ON) { + Log.e(tag, msg, e) + } +} + +fun loge(msg: String) = loge(TAG, msg) + +fun loge(msg: String, e: Throwable) = loge(TAG, msg, e) + +fun logi(tag: String, msg: String) { + if (DEBUG_ON) { + Log.i(tag, msg) + } +} + +fun Context.complexUnitSp(value: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, resources.displayMetrics) + +fun Context.complexUnitDip(value: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, resources.displayMetrics) + +fun Context.toColor(@ColorRes colorId: Int) = ActivityCompat.getColor(this, colorId) + +fun Int.toNumberString(): String = String.format("%02d", this) + +fun Float.extremeValue(spacing: Float, upOrDown: Boolean): Float = + extremeValue(spacing, 0, upOrDown) + +private fun Float.extremeValue( + spacing: Float, + scale: Int, + upOrDown: Boolean, +): Float { + val min = spacing * scale + val max = spacing * (scale + 1) + return if (this > max) { + extremeValue(spacing, scale + 1, upOrDown) + } else if (this < min) { + extremeValue(spacing, scale - 1, upOrDown) + } else if (upOrDown) { + max + } else { + min + } +} + +/** + * endTime 必须大于 startTime,且 endTime 一定是正数,startTime可能为负数 + * */ +infix fun Float.floatTimeMinutes(startTime: Float): Int { + val startH = startTime.toInt() + val startM = startTime.minus(startH).times(100) + val start = startH.times(60).plus(startM) + + val endH = this.toInt() + val endM = this.minus(endH).times(100) + val end = endH.times(60).plus(endM) + + return (end - start).roundToInt() +} + +fun Float.floatTimePlus(minute: Int): Float { + if (minute == 0) return this + var h = this.toInt() + var m = this.minus(h).times(100).roundToInt() + val total = 60.times(h).plus(m).plus(minute) + h = total / 60 + m = total % 60 + return h.toFloat().plus(m.div(100f)) +} + +fun Float.floatTimeString(): String { + var hour = this.toInt() + var min = this.minus(hour).times(100).roundToInt() + if (hour <= 0 && min < 0) { + hour += 23 + min += 60 + } else if (hour < 0 && min == 0) { + hour += 24 + } + return String.format("%02d:%02d", hour, min) +} + +fun Float.floatTimeHourString(): String { + var hour = this.toInt() + val min = this.minus(hour).times(100).roundToInt() + if (hour < 0 || min < 0) { + hour += 23 + } + return String.format("%02d", hour) +} + +fun Double.round(): Double = this.round(1) + +fun Double.round(times: Int): Double { + val scale = 10.0.pow(times) + return this.times(scale).roundToInt().div(scale) +} + +fun Float.round(): Float = this.round(1) + +fun Float.round(times: Int): Float { + val scale = 10.0f.pow(times) + return this.times(scale).roundToInt().div(scale) +} + +fun Long.formatTime(): String { + val totalMinutes = this / 60 / 1000L + val h = totalMinutes / 60 + val m = totalMinutes % 60 + return "${h}h ${m}m" + +} + +fun Number.formatPercent(): String = this.toFloat().times(100).toInt().toPercent() + +fun Int.toPercent(): String = "${this}%" + +var toast: Toast? = null + +fun Context.toast(tip: String) { + toast?.cancel() + toast = Toast.makeText(this, tip, Toast.LENGTH_SHORT) + .apply { show() } +} + +fun Context.toast(@StringRes tip: Int) { + toast?.cancel() + toast = Toast.makeText(this, tip, Toast.LENGTH_SHORT) + .apply { show() } +} + +fun Context.goEnableLocationServicePage() { + val intent = Intent() + .setAction(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + startActivity(intent) + } catch (ex: ActivityNotFoundException) { + // The Android SDK doc says that the location settings activity + // may not be found. In that case show the general settings. + // General settings activity + intent.action = Settings.ACTION_SETTINGS + try { + startActivity(intent) + } catch (e: Exception) { + toast("Can not find the LOCATION setting page.") + } + } +} + +fun todayCalendar(): Calendar = + Calendar.getInstance().apply { + this[Calendar.HOUR_OF_DAY] = 0 + this[Calendar.MINUTE] = 0 + this[Calendar.SECOND] = 0 + this[Calendar.MILLISECOND] = 0 + } + +/** + * 获取过去指定天数的日历对象,时间设置为那一天的开始时刻(0点0分0秒) + * @param daysAgo 过去的天数,例如: + * 1 = 昨天 + * 2 = 前天 + * 7 = 一周前 + * @return Calendar 对象,时间设置为指定日期的0点0分0秒 + */ +fun getPastDayCalendar(daysAgo: Int): Calendar = + todayCalendar().apply { + add(Calendar.DAY_OF_YEAR, -daysAgo) + } + +/** + * Calendar扩展函数,用于获取过去指定天数的日历对象 + * @param daysAgo 过去的天数 + * @return Calendar 对象 + */ +fun Calendar.minusDays(daysAgo: Int): Calendar = + (clone() as Calendar).apply { + add(Calendar.DAY_OF_YEAR, -daysAgo) + } + +fun Long.toDateString(format: String, locale: Locale): String = + if (this == 0L) "-" else SimpleDateFormat(format, locale).format(Date(this)) + +fun Long.toDateString() = toDateString("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + +fun Calendar.isToday(): Boolean = + this.timeInMillis == todayCalendar().timeInMillis + +fun Calendar.isYesterday(): Boolean { + val today = todayCalendar().apply { + //Is today the first day of this year? + val is1stDayOfYear = this[Calendar.DAY_OF_YEAR] == 1 + roll(Calendar.DAY_OF_YEAR, false) + if (is1stDayOfYear) { + // If today is the first day of this year, you also need to roll back the year. + roll(Calendar.YEAR, false) + } + }.timeInMillis + return this.timeInMillis == today +} + +fun Calendar.isThisYear(): Boolean = + todayCalendar()[Calendar.YEAR] == this[Calendar.YEAR] + +fun Calendar?.getDayStartCal(): Calendar { + val clone = if (this == null) todayCalendar() + else clone() as Calendar + clone[Calendar.HOUR_OF_DAY] = 0 + clone[Calendar.MINUTE] = 0 + clone[Calendar.SECOND] = 0 + clone[Calendar.MILLISECOND] = 0 + return clone +} + +fun Calendar?.getDayEndCal(): Calendar { + val clone = if (this == null) todayCalendar() + else clone() as Calendar + clone[Calendar.HOUR_OF_DAY] = 23 + clone[Calendar.MINUTE] = 59 + clone[Calendar.SECOND] = 59 + clone[Calendar.MILLISECOND] = 999 + return clone +} + +fun Calendar?.getDayEnd(): Long { + return getDayEndCal().timeInMillis +} + +fun Calendar?.getDayStart(): Long { + return getDayStartCal().timeInMillis +} + +/** + * @param zeroClockTimestamp Today's 0:00 time stamp. + * @return Float. For example: + * + * -0.10 => 23:50, yesterday + * + * 0.10 => 0:10 ,today + * */ +fun Long.toFloatTime(zeroClockTimestamp: Long): Float { + //求出总秒数 + val diffSeconds = this.minus(zeroClockTimestamp).div(1000L) + val h = diffSeconds / 3600 + val m = diffSeconds % 3600 / 60 + return m.div(100f).plus(h).round(2) +} + +fun Context.formatTime(duration: Long?): String { + return if (duration == null) "-" + else { + val totalMinutes = duration / 60 / 1000L + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + if (hours > 0 && minutes > 0) { + getString(R.string.time_format_hh_mm, hours, minutes) + } else if (hours > 0) { + getString(R.string.time_format_hh, hours) + } else { + getString(R.string.time_format_mm, minutes) + } + } +} + +fun Long.toSizeString(): String { + return String.format("%.2f KB", this / 1024.0f) +} + +fun Number.toPercent(): String = "${this}%" + +@SuppressLint("UnspecifiedImmutableFlag") +fun Application.restartApp() { + postDelay({ + val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + or Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + } + startActivity(intent) + Process.killProcess(Process.myPid()) + loge("exitProcess") + exitProcess(0) + }, 200L) +} + +@Suppress("UNCHECKED_CAST") +fun T.minus(value: T): T = (this.toFloat() - value.toFloat()) as T + +infix fun T.moreThan(value: T): Boolean = this.toFloat() > value.toFloat() + + +fun Context.toRingColor(colorInt: Int): String { + return when (colorInt) { + PRODUCT_COLOR_DEEP_BLACK -> getString(R.string.ring_color_deep_black) + PRODUCT_COLOR_SILVER -> getString(R.string.ring_color_sliver) + PRODUCT_COLOR_GOLDEN -> getString(R.string.ring_color_golden) + PRODUCT_COLOR_ROSE_GOLD -> getString(R.string.ring_color_rose_gold) + PRODUCT_COLOR_GOLD_SILVER_MIXED -> getString(R.string.ring_color_gold_silver_mixed) + PRODUCT_COLOR_PURPLE_SILVER_MIXED -> getString(R.string.ring_color_purple_silver_mixed) + PRODUCT_COLOR_ROSE_GOLD_SILVER_MIXED -> getString(R.string.ring_color_rose_gold_silver_mixed) + else -> "-" + } +} + +fun Context.getProviderFileUri(file: File): Uri { + return FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileProvider", + file + ) +} + + +fun Context.shareFile(file: Pair) { + grantUriPermission(packageName, file.second, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val intent = Intent(Intent.ACTION_SEND) + .apply { + type = "application/octet-stream" + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + putExtra(Intent.EXTRA_STREAM, file.second) + putExtra(Intent.EXTRA_TITLE, file.first) + putExtra(Intent.EXTRA_SUBJECT, file.first) + putExtra(Intent.EXTRA_TEXT, file.first) + } + startActivity(Intent.createChooser(intent, file.first)) +} + +fun TextView.setTextColorId(@ColorRes id: Int) { + setTextColor(context.toColor(id)) +} + +fun asSimpleDateFormat(format: String, locale: Locale) = SimpleDateFormat(format, locale) + +fun asSimpleDateFormat() = asSimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.getDefault()) +fun String.stringDateToTime(): String { + return asSimpleDateFormat().parse(this).let { + if (it == null) "-" + else asSimpleDateFormat("HH:mm", Locale.getDefault()).format(it) + } +} + +fun Long?.asCalendar(): Calendar { + return if (this == null) todayCalendar() + else Calendar.getInstance().run { + timeInMillis = this@asCalendar + this + } +} + + +fun Calendar?.toYD(): String { + val cal = this ?: todayCalendar() + return String.format("%d-%03d", cal[Calendar.YEAR], cal[Calendar.DAY_OF_YEAR]) +} + +fun TextView.drawableEnd(@DrawableRes id: Int = 0) = + setCompoundDrawablesWithIntrinsicBounds(0, 0, id, 0) + +fun Context.cmdErrorTip(code: Int) { + when (code) { + 0 -> toast(R.string.cmd_execute_success) + 1 -> toast(R.string.cmd_execute_failed_1) + 2 -> toast(R.string.cmd_execute_failed_2) + 3 -> toast(R.string.cmd_execute_failed_3) + 4 -> toast(R.string.cmd_execute_failed_4) + 5 -> toast(R.string.cmd_execute_failed_5) + 6 -> toast(R.string.cmd_execute_failed_6) + } +} + +@OptIn(ExperimentalStdlibApi::class) +fun md5Checker(file: ByteArray): String { + val md: MessageDigest = MessageDigest.getInstance("MD5") + md.update(file, 0, file.size) + val md5Bytes = md.digest() +// logi("md5Checker ${md5Bytes.toByteArrayString()}") + val sb = StringBuilder() + md5Bytes.forEach { + sb.append(it.toHexString()) + } +// logi("md5Checker $sb") +// val bigInt = BigInteger(1, md5Bytes) +// return bigInt.toString(16) + return sb.toString() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/CenteredLayoutManager.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/CenteredLayoutManager.kt new file mode 100644 index 0000000..916cddb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/CenteredLayoutManager.kt @@ -0,0 +1,49 @@ +package com.whitefish.app.view + +import android.content.Context +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.abs + +class CenteredLayoutManager(context: Context) : LinearLayoutManager(context) { + + override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int { + val scroll = super.scrollVerticallyBy(dy, recycler, state) + offsetChildrenVertical(-dy / 2) // 缓慢滚动,创建滚轮效果 + return scroll + } + + override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) { + super.onLayoutChildren(recycler, state) + scaleCenterItem() + } + + private fun scaleCenterItem() { + val midpoint = height / 2.0f + val s0 = 1.5f // 放大的比例 + val s1 = 1.0f // 原始比例 + + for (i in 0 until childCount) { + val child = getChildAt(i) + child?.let { + val childMidpoint = (getDecoratedTop(child) + getDecoratedBottom(child)) / 2.0f + if (abs(midpoint - childMidpoint) < (it.height / 2)) { + // 如果子项的中心点在RecyclerView的中心点附近,则放大 + child.scaleX = s0 + child.scaleY = s0 + } else { + // 否则恢复原始比例 + child.scaleX = s1 + child.scaleY = s1 + } + } + } + } + + override fun onScrollStateChanged(state: Int) { + super.onScrollStateChanged(state) + if (state == RecyclerView.SCROLL_STATE_IDLE) { + scaleCenterItem() + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/CircularProgressView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/CircularProgressView.kt new file mode 100644 index 0000000..d3d16e1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/CircularProgressView.kt @@ -0,0 +1,139 @@ +package com.whitefish.app.view + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PaintFlagsDrawFilter +import android.graphics.RectF +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import com.whitefish.app.R + + +class CircularProgressView(context: Context, attrs: AttributeSet) : View(context, attrs) { + private var mPaint: Paint = Paint() + private var shadowPaint: Paint = Paint() + + private var annulusWidth = 0 // 圆环宽度 + + private var annulusColor = Color.BLACK + + private var progressColor = Color.WHITE + + private var shadowColor = Color.GRAY // 阴影颜色 + + private var progress = 0 // 当前进度 + + private val maxProgress = 100 // 最大进度,默认100 + + private var startAngle = -180 // 开始圆点角度 + private var mHalfCircular = false + + init { + mPaint.isAntiAlias = true + mPaint.isDither = true // 设置防抖动 + mPaint.isFilterBitmap = true // 设置位图过滤 + mPaint.style = Paint.Style.STROKE // 设置画笔为描边 + mPaint.strokeCap = Paint.Cap.ROUND // 设置画笔端点为圆角 + + shadowPaint.isAntiAlias = true + shadowPaint.style = Paint.Style.STROKE + shadowPaint.strokeCap = Paint.Cap.ROUND + shadowPaint.isDither = true + shadowPaint.isFilterBitmap = true + shadowPaint.setShadowLayer(10f, 5f, 5f, shadowColor) // 设置阴影层 + + // 获取自定义属性 + val value: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.CircularProgressView) + + val indexCount = value.getIndexCount() + for (i in 0 until indexCount) { + when (val param = value.getIndex(i)) { + R.styleable.CircularProgressView_startAngle -> startAngle = value.getInt(param, 90) + R.styleable.CircularProgressView_annulusWidth -> annulusWidth = + value.getDimensionPixelSize( + param, TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics + ).toInt() + ) + + R.styleable.CircularProgressView_annulusColor -> annulusColor = + value.getColor(param, Color.TRANSPARENT) + + R.styleable.CircularProgressView_progressColor -> progressColor = + value.getColor(param, Color.TRANSPARENT) + R.styleable.CircularProgressView_halfCircular -> mHalfCircular = value.getBoolean(param,false) + R.styleable.CircularProgressView_progress -> progress = value.getInteger(R.styleable.CircularProgressView_progress,0) + + } + } + value.recycle() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.setDrawFilter( + PaintFlagsDrawFilter( + 0, + Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG + ) + ) + + // 绘制圆环 + val centre = width / 2 + val radius = centre - annulusWidth / 2 - 10 + mPaint.color = annulusColor + mPaint.strokeWidth = annulusWidth.toFloat() + + if (mHalfCircular){ + val ovalBackground = RectF( + (centre - radius).toFloat(), + (centre - radius).toFloat(), + (centre + radius).toFloat(), + (centre + radius).toFloat() + ) + canvas.drawArc(ovalBackground,180f, 180f, false, mPaint) + }else{ + canvas.drawCircle(centre.toFloat(), centre.toFloat(), radius.toFloat(), mPaint) + } + + + // 绘制半圆弧进度条的阴影 + shadowPaint.strokeWidth = annulusWidth.toFloat() + val ovalStrokeShadow = RectF( + (centre - radius).toFloat(), + (centre - radius).toFloat(), + (centre + radius).toFloat(), + (centre + radius).toFloat() + ) + val sweepAngleShadow = (progress.toFloat() / maxProgress.toFloat()) * if (mHalfCircular) 180 else 360 // 仅绘制180度的半圆弧 + canvas.drawArc(ovalStrokeShadow, startAngle.toFloat(), sweepAngleShadow, false, shadowPaint) + + + // 绘制圆弧进度条 + val progressRadius = centre - annulusWidth / 2 - 10 + mPaint.color = progressColor + mPaint.strokeWidth = annulusWidth.toFloat() + val ovalStroke = RectF( + (centre - progressRadius).toFloat(), + (centre - progressRadius).toFloat(), + (centre + progressRadius).toFloat(), + (centre + progressRadius).toFloat() + ) + val sweepAngle = (progress.toFloat() / maxProgress.toFloat()) * if (mHalfCircular) 180 else 360 + canvas.drawArc(ovalStroke, startAngle.toFloat(), sweepAngle, false, mPaint) + + } + + /** + * 设置进度 + * @param progress 进度值 + */ + fun setProgress(progress: Int) { + this.progress = progress + invalidate() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/ConnectErrorDialog.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/ConnectErrorDialog.kt new file mode 100644 index 0000000..f12dd25 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/ConnectErrorDialog.kt @@ -0,0 +1,55 @@ +package com.whitefish.app.view + +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 com.whitefish.app.R +import com.whitefish.app.databinding.DialogConnectErrorBinding + +class ConnectErrorDialog : DialogFragment() { + private lateinit var mBinding:DialogConnectErrorBinding + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mBinding = DataBindingUtil.inflate( + inflater, + R.layout.dialog_connect_error, + container, + false + ) + return mBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setWindow() + mBinding.ok.setOnClickListener { + dismiss() + } + } + + private fun setWindow() { + val window = dialog!!.window + window?.decorView?.setPadding(0, 0, 0, 0) + val layoutParams = window?.attributes + layoutParams?.width = WindowManager.LayoutParams.WRAP_CONTENT + layoutParams?.height = WindowManager.LayoutParams.WRAP_CONTENT + window?.attributes = layoutParams + window?.decorView?.setBackgroundColor(Color.TRANSPARENT) + window?.setGravity(Gravity.CENTER) + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + // 添加这一行来禁用背景调光效果 + window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/LineProgressView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/LineProgressView.kt new file mode 100644 index 0000000..efae4a3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/LineProgressView.kt @@ -0,0 +1,101 @@ +package com.whitefish.app.view + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PaintFlagsDrawFilter +import android.util.AttributeSet +import android.view.View +import com.whitefish.app.R + +class LineProgressView(context: Context, attrs: AttributeSet) : View(context, attrs) { + private var mPaint: Paint = Paint() + private var shadowPaint: Paint = Paint() + + private var backgroundColor = Color.BLACK + + private var progressColor = Color.WHITE + + private var shadowColor = Color.GRAY // 阴影颜色 + + private var mProgress = 0 // 当前进度 + + private val maxProgress = 100 // 最大进度,默认100 + private val filter = PaintFlagsDrawFilter(0,Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + + + init { + mPaint.isAntiAlias = true + mPaint.isDither = true // 设置防抖动 + mPaint.isFilterBitmap = true // 设置位图过滤 + mPaint.style = Paint.Style.STROKE // 设置画笔为描边 + mPaint.strokeCap = Paint.Cap.ROUND // 设置画笔端点为圆角 + + shadowPaint.isAntiAlias = true + shadowPaint.style = Paint.Style.STROKE + shadowPaint.strokeCap = Paint.Cap.ROUND + shadowPaint.isDither = true + shadowPaint.isFilterBitmap = true + shadowPaint.setShadowLayer(10f, 5f, 5f, shadowColor) // 设置阴影层 + + // 获取自定义属性 + val value: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.LineProgressView) + + val indexCount = value.getIndexCount() + for (i in 0 until indexCount) { + when (val param = value.getIndex(i)) { + + R.styleable.LineProgressView_lineProgressBackgroundColor -> backgroundColor = + value.getColor(param, Color.TRANSPARENT) + + R.styleable.LineProgressView_lineProgressProgressColor -> progressColor = + value.getColor(param, Color.TRANSPARENT) + + } + } + value.recycle() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.setDrawFilter(filter) + + // 绘制背景直线 + val padding = 10 + val lineWidth = width - padding * 2 + + mPaint.color = backgroundColor + mPaint.strokeWidth = height.toFloat() + canvas.drawLine(padding.toFloat(), (height / 2).toFloat(), (width - padding).toFloat(), (height / 2).toFloat(), mPaint) + + // 绘制进度条阴影 + shadowPaint.strokeWidth = height.toFloat() + val progressXShadow = padding + (lineWidth * mProgress / maxProgress.toFloat()) + canvas.drawLine(padding.toFloat(), (height / 2).toFloat(), progressXShadow, (height / 2).toFloat(), shadowPaint) + + // 绘制进度条 + mPaint.color = progressColor + val progressX = padding + (lineWidth * mProgress / maxProgress.toFloat()) + canvas.drawLine(padding.toFloat(), (height / 2).toFloat(), progressX, (height / 2).toFloat(), mPaint) + } + + /** + * 设置进度 + * @param progress 进度值 + */ + fun setProgress(progress: Int) { + mProgress = progress + invalidate() + } + + fun setProgressColor(color:Int){ + progressColor = color + invalidate() + } + + fun getProgress():Int{ + return mProgress + } +} diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/NavigationBarView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/NavigationBarView.kt new file mode 100644 index 0000000..81c5e36 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/NavigationBarView.kt @@ -0,0 +1,24 @@ +package com.whitefish.app.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import com.blankj.utilcode.util.BarUtils + +class NavigationBarView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = 0, defStyleRes:Int = 0 +) : FrameLayout(context, attrs,defStyleAttr,defStyleRes) { + + init { + val navBarHeight = getNavBarHeight() + setPadding(0, navBarHeight,0,0) + } + + private fun getNavBarHeight(): Int { + if (isInEditMode) { + return 0 + } + return BarUtils.getNavBarHeight() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/ResourceImageView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/ResourceImageView.kt new file mode 100644 index 0000000..a482576 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/ResourceImageView.kt @@ -0,0 +1,65 @@ +package com.whitefish.app.view + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CircleCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestOptions +import com.whitefish.app.R + + +class ResourceImageView(context:Context,attrs:AttributeSet):androidx.appcompat.widget.AppCompatImageView(context,attrs) { + var imageUrl:String? + init { + val typedArray: TypedArray = + context.obtainStyledAttributes(attrs, R.styleable.ResourceImageView) + + + val circular = typedArray.getBoolean(R.styleable.ResourceImageView_circular,false) + val radius = typedArray.getInteger(R.styleable.ResourceImageView_radius,0) + + imageUrl = typedArray.getString(R.styleable.ResourceImageView_imageUrl).apply { + loadImage(this,circular,radius) + } + + typedArray.recycle() + } + + private fun loadImage(url: String?,circular:Boolean,radius:Int) { + url?.let { + Glide.with(context) + .load(it) + .apply { + + if (radius!=0) { + transform(RoundedCorners(radius)) + } + + if (circular){ + transform(CircleCrop()) + } + } + .into(this) + } + } + + private fun loadImage(url: Int?,circular:Boolean,radius:Int) { + url?.let { + Glide.with(context) + .load(it) + .apply { + + if (radius!=0) { + transform(RoundedCorners(radius)) + } + + if (circular){ + transform(CircleCrop()) + } + } + .into(this) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/SleepChartView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/SleepChartView.kt new file mode 100644 index 0000000..1942753 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/SleepChartView.kt @@ -0,0 +1,378 @@ +package com.whitefish.app.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import androidx.room.Entity +import kotlinx.android.parcel.Parcelize +import lib.linktop.nexring.api.SleepStage +import kotlin.math.max + +/** + * 自定义View,用于绘制睡眠状态图表 + * 图表将显示不同时间段的睡眠状态,以柱状图的形式展示 + * 不同状态之间用窄柱连接,覆盖整个区域,形成完整的睡眠图表 + */ +/** + * Sleep state + * lib.linktop.nexring.api.SLEEP_STATE_WAKE; (0) + * lib.linktop.nexring.api.SLEEP_STATE_REM; (1) + * lib.linktop.nexring.api.SLEEP_STATE_LIGHT; (2) + * lib.linktop.nexring.api.SLEEP_STATE_DEEP; (3) + * lib.linktop.nexring.api.SLEEP_STATE_NAP; (4) + */ +class SleepChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + // 睡眠状态常量 + companion object { +// const val SLEEP_STATE_AWAKE = 0 // 清醒 +// const val SLEEP_STATE_LIGHT = 1 // 浅睡 +// const val SLEEP_STATE_DEEP = 2 // 深睡 +// const val SLEEP_STATE_REM = 3 // 快速眼动睡眠 + + private const val BAR_MARGIN = 0f // 柱子之间的间距(dp),设为0表示连续 + private const val CORNER_RADIUS = 3f // 圆角半径(dp) + private const val CONNECTOR_WIDTH = 0.5f // 连接柱宽度(dp) + private const val ROW_SPACE = 10f // 柱间宽度 + } + + // 颜色配置 + private val stateColors = intArrayOf( + Color.parseColor("#FFAB91"), // 清醒状态 - 珊瑚色 + Color.parseColor("#CE93D8"), // 浅睡状态 - 浅紫色 + Color.parseColor("#9575CD"), // 深睡状态 - 中紫色 + Color.parseColor("#5E35B1") // REM状态 - 深紫色 + ) + + private lateinit var gradientPaint: Paint + + private val barRect = RectF() + // 绘制工具 + private val barPaint = Paint().apply { + isAntiAlias = true + style = Paint.Style.FILL + } + + private val barPath = Path() + + private val connectorRect = RectF() + + // 数据 + private val sleepData = mutableListOf() + + private val nextBarRect = RectF() + + // 图表尺寸计算相关 + private var barWidthMultiplier = 1f // 柱宽度的比例因子 + private var totalDuration = 0F // 总时长(分钟) + private var maxDuration = 0F // 最长段时长 + private var stateHeight = 0f // 每个状态的高度 + private var cornerRadiusPx = 0f // 圆角半径(像素) + private var connectorWidthPx = 0f // 连接柱宽度(像素) + private var rowSpace = ROW_SPACE * resources.displayMetrics.density // 柱间上下巨鹿 + + /** + * 设置睡眠数据 + * @param data 睡眠数据段列表 + */ + fun setSleepData(data: List) { + sleepData.clear() + sleepData.addAll(data) + + // 计算总时长和最长段 + totalDuration = 0F + maxDuration = 0f + for (segment in data) { + val duration = segment.durationMinutes + totalDuration += duration + maxDuration = max(maxDuration, duration) + } + + invalidate() + } + + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + setupGradientPaint() + } + + + /** + * 设置全局渐变色Paint + */ + private fun setupGradientPaint() { + if (width == 0 || height == 0) return + + // 创建垂直线性渐变,覆盖整个视图高度 + val gradient = LinearGradient( + 0f, 0f, // x0, y0 (起点) + 0f, height.toFloat(), // x1, y1 (终点) + stateColors, // 颜色数组 + null, // 使用均匀分布的位置 + Shader.TileMode.CLAMP // 不重复渐变 + ) + + // 创建渐变Paint + gradientPaint = Paint(barPaint).apply { + shader = gradient + isAntiAlias = true + } + } + + /** + * 设置柱宽度系数,可用于缩放视图 + * @param multiplier 乘数因子 + */ + fun setBarWidthMultiplier(multiplier: Float) { + barWidthMultiplier = multiplier + invalidate() + } + + + /** + * 设置柱高度系数,可用于缩放视图 + * @param multiplier 乘数因子 + */ + fun setBarHeight(multiplier: Float){ + rowSpace = multiplier * resources.displayMetrics.density + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + // 计算每个状态高度 (总高度除以状态数) + stateHeight = measuredHeight / 4f + + // 将dp转换为像素 + cornerRadiusPx = CORNER_RADIUS * resources.displayMetrics.density + connectorWidthPx = CONNECTOR_WIDTH * resources.displayMetrics.density + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (sleepData.isEmpty()) return + + val barMarginPx = BAR_MARGIN * resources.displayMetrics.density + + // 计算每分钟对应的宽度 + val minuteWidth = width.toFloat() / max(totalDuration, 1f) + + var startX = 0f + + // 绘制每个睡眠段 + for (i in sleepData.indices) { + val segment = sleepData[i] + val nextSegment = if (i < sleepData.size - 1) sleepData[i + 1] else null + + // 计算当前柱子的宽度和位置 + val barWidth = segment.durationMinutes * minuteWidth * barWidthMultiplier + + // 计算柱子的顶部和底部位置 + val top = segment.state * stateHeight + val bottom = top + stateHeight - rowSpace + + //start位置剪去连接柱的宽度,避免出现锯齿 + barRect.set(startX - connectorWidthPx, top, startX + barWidth - barMarginPx, bottom) + + // 绘制选择性圆角的柱子 + if (nextSegment != null) { + if (segment.state != nextSegment.state) { + // 根据下一个柱子的位置决定哪些角是圆角,哪些是直角 + val isNextSegmentAbove = nextSegment.state < segment.state + + drawBarWithSelectiveCorners( + canvas, + barRect, + topLeftRadius = if (i == 0) cornerRadiusPx else cornerRadiusPx, + topRightRadius = if (isNextSegmentAbove) 0f else cornerRadiusPx, + bottomRightRadius = if (isNextSegmentAbove) cornerRadiusPx else 0f, + bottomLeftRadius = if (i == 0) cornerRadiusPx else cornerRadiusPx + ) + } else { + // todo 不应该出现两段状态相同且连续的数据,如果真有这样的数据,需要合并为一段 + +// canvas.drawRoundRect(barRect, cornerRadiusPx, cornerRadiusPx, gradientPaint) + } + } else { + // 如果是最后一个柱子,所有角都是圆角 + canvas.drawRoundRect(barRect, cornerRadiusPx, cornerRadiusPx, gradientPaint) + } + + // 如果有下一段且状态不同,绘制连接柱 + if (nextSegment != null && segment.state != nextSegment.state) { + // 确定连接柱的位置 + val endX = startX + barWidth + val nextTop = nextSegment.state * stateHeight + val nextBottom = nextTop + stateHeight - rowSpace + + // 设置连接柱的坐标 + // 注意:对于顶部和底部的区域,需要避开圆角部分 + connectorRect.set( + endX - connectorWidthPx, // 左边 + if (segment.state <= nextSegment.state) { + // 当前状态在上或平级,连接顶部需考虑当前柱子圆角 + top + cornerRadiusPx + } else { + // 当前状态在下,连接顶部需考虑下一柱子圆角 + nextTop + cornerRadiusPx + }, + endX, // 右边 + if (segment.state >= nextSegment.state) { + // 当前状态在下或平级,连接底部需考虑当前柱子圆角 + bottom - cornerRadiusPx + } else { + // 当前状态在上,连接底部需考虑下一柱子圆角 + nextBottom - cornerRadiusPx + } + ) + + // 绘制连接柱 + canvas.drawRect(connectorRect, gradientPaint) + + //为下一个柱子也设置选择性圆角 + if (i < sleepData.size - 1) { + val nextBarWidth = nextSegment.durationMinutes * minuteWidth * barWidthMultiplier + + //使用预分配的对象 + nextBarRect.also { + it.left = endX + it.top = nextTop + it.bottom = nextBottom + it.right = endX + nextBarWidth - barMarginPx + } + + // 判断下一个柱子是在当前柱子的上方还是下方 + val isNextSegmentAbove = nextSegment.state < segment.state + + // 提前为下一个柱子的左侧设置选择性圆角 + // 如果当前柱子在下一个柱子上方,则下一个柱子左下为直角 + // 如果当前柱子在下一个柱子下方,则下一个柱子左上为直角 + // 由于我们在循环中会再次绘制这个柱子,所以这里只是为了处理连接处的视觉效果 + if (i < sleepData.size - 2) { + // 如果不是最后一个柱子,需要处理它的右侧圆角 + val afterNextSegment = sleepData[i + 2] + val afterNextIsAbove = afterNextSegment.state < nextSegment.state + + drawBarWithSelectiveCorners( + canvas, + nextBarRect, + topLeftRadius = if (isNextSegmentAbove) cornerRadiusPx else 0f, + topRightRadius = if (afterNextIsAbove) 0f else cornerRadiusPx, + bottomRightRadius = if (afterNextIsAbove) cornerRadiusPx else 0f, + bottomLeftRadius = if (isNextSegmentAbove) 0f else cornerRadiusPx + ) + } else { + // 如果是最后一个柱子,右侧全部为圆角 + drawBarWithSelectiveCorners( + canvas, + nextBarRect, + topLeftRadius = if (isNextSegmentAbove) cornerRadiusPx else 0f, + topRightRadius = cornerRadiusPx, + bottomRightRadius = cornerRadiusPx, + bottomLeftRadius = if (isNextSegmentAbove) 0f else cornerRadiusPx + ) + } + } + } + + // 更新下一段的起始位置 + startX += barWidth + } + } + + /** + * 绘制具有选择性圆角的矩形 + * 可以分别控制四个角的圆角半径 + */ + private fun drawBarWithSelectiveCorners( + canvas: Canvas, + rect: RectF, + topLeftRadius: Float, + topRightRadius: Float, + bottomRightRadius: Float, + bottomLeftRadius: Float + ) { + barPath.reset() + + val left = rect.left + val top = rect.top + val right = rect.right + val bottom = rect.bottom + + // 左上角 + if (topLeftRadius > 0) { + barPath.moveTo(left, top + topLeftRadius) + barPath.quadTo(left, top, left + topLeftRadius, top) + } else { + barPath.moveTo(left, top) + } + + // 右上角 + if (topRightRadius > 0) { + barPath.lineTo(right - topRightRadius, top) + barPath.quadTo(right, top, right, top + topRightRadius) + } else { + barPath.lineTo(right, top) + } + + // 右下角 + if (bottomRightRadius > 0) { + barPath.lineTo(right, bottom - bottomRightRadius) + barPath.quadTo(right, bottom, right - bottomRightRadius, bottom) + } else { + barPath.lineTo(right, bottom) + } + + // 左下角 + if (bottomLeftRadius > 0) { + barPath.lineTo(left + bottomLeftRadius, bottom) + barPath.quadTo(left, bottom, left, bottom - bottomLeftRadius) + } else { + barPath.lineTo(left, bottom) + } + + barPath.close() + canvas.drawPath(barPath, gradientPaint) + } + + +} +/** + * 数据类,表示一段睡眠 + * @param state 睡眠状态 + * @param durationMinutes 持续时间(分钟) + */ +@Parcelize +@Entity +data class SleepSegment( + val state: Int, // 睡眠状态 + val durationMinutes: Float // 持续时间(分钟) +) : Parcelable + + +fun SleepStage.toSegment(): SleepSegment{ + return SleepSegment( + state = state, + durationMinutes = if (endT < 0){ + //昨天的阶段 + (startT * -1) + (endT * -1) + }else{ + endT - startT + } + ) +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/StatusBarView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/StatusBarView.kt new file mode 100644 index 0000000..d56c068 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/StatusBarView.kt @@ -0,0 +1,25 @@ +package com.whitefish.app.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import com.blankj.utilcode.util.BarUtils + +class StatusBarView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = 0, defStyleRes:Int = 0 +) : FrameLayout(context, attrs,defStyleAttr,defStyleRes) { + + init { + val statusBarHeight = getStatusBarHeight() + setPadding(0, statusBarHeight,0,0) + } + + private fun getStatusBarHeight(): Int { + if (isInEditMode) { + return 0 + } + //状态栏高度适配AutoSize框架 + return BarUtils.getStatusBarHeight() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/PickerAdapter.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/PickerAdapter.kt new file mode 100644 index 0000000..d9b0a40 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/PickerAdapter.kt @@ -0,0 +1,53 @@ +package com.whitefish.app.view.datepicker + +import android.util.TypedValue +import android.view.Gravity +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +/** + * 简易适配器 + * Mail: lilifeng@tongxue-inc.com + * Blog: https://leefeng.top + * Develop Date:2020-01-10 + * cellHeight 单个cell的高度 + * dateShowSize 一屏cell的个数 + * txtsize cell文字的大小 px + * textcolor cell文字的颜色 + */ +class PickerAdapter(val cellHeight: Int, + val showSiz: Int, + val txtsize: Float, + val textcolor: Int):RecyclerView.Adapter() { + private val array = ArrayList() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return object : RecyclerView.ViewHolder(TextView(parent.context).apply { + layoutParams = + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, cellHeight) + gravity = Gravity.CENTER// or Gravity.END + setTextSize(TypedValue.COMPLEX_UNIT_PX, txtsize) + setTextColor(textcolor) + }) {} + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder.itemView as TextView).text = when (position) { + in (0 until showSiz / 2) -> "" + in itemCount - showSiz / 2 until itemCount -> "" + else -> array[position + showSiz/2] + } + + } + + override fun getItemCount(): Int { + return array.size + showSiz -1 + } + + + fun setData(list: List) { + array.clear() + array.addAll(list) + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/PickerView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/PickerView.kt new file mode 100644 index 0000000..97ca1b4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/PickerView.kt @@ -0,0 +1,60 @@ +package com.whitefish.app.view.datepicker + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.core.view.children +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSnapHelper +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.pow + +/** + * 滚轮View + * Mail: lilifeng@tongxue-inc.com + * Blog: https://leefeng.top + * Develop Date:2020-01-10 + */ +open class PickerView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + init { + layoutManager = LinearLayoutManager(context) + overScrollMode = ViewGroup.OVER_SCROLL_NEVER + LinearSnapHelper().attachToRecyclerView(this) + } + + var enableAlpha = true + override fun dispatchDraw(canvas: Canvas) { + children.forEach { + val cellCenter = (it.top + it.bottom) / 2f + var f = cellCenter / (measuredHeight / 2f) + val revert = f > 1 + if (revert) f = 2 - f + val scale = 0.7f + f * 0.3f + if (scale.isNaN()) return + it.scaleX = scale + it.scaleY = scale + if (enableAlpha) + it.alpha = 0.3f + f * 0.7f + val degree = 90 - f * 90 + it.rotation + it.rotationX = if (revert) -degree else degree + if (degree < 90) { + val s = degree.toInt() / 90f + it.translationY = (if (revert) -(s.pow(3.0f)) else (s).pow(3.0f)) * it.height + } else { + it.translationY = if (revert) it.height / 1f else -it.height / 1f + } + drawChild(canvas, it, drawingTime) + it.invalidate() + } + } + + + interface DrawListener { + fun drawBelow(canvas: Canvas?, width: Int, height: Int, cellHeight: Int) + fun drawOver(canvas: Canvas?, width: Int, height: Int, cellHeight: Int) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/SimplePickerView.kt b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/SimplePickerView.kt new file mode 100644 index 0000000..ca51ad4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/whitefish/app/view/datepicker/SimplePickerView.kt @@ -0,0 +1,157 @@ +package com.whitefish.app.view.datepicker + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.whitefish.app.R + +/** + * 选择器 + * Mail: lilifeng@tongxue-inc.com + * Blog: https://leefeng.top + * Develop Date:2020-01-10 + */ +class SimplePickerView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : PickerView(context, attrs, defStyleAttr) { + + private var padTop: Float + private var backColor: Int + private var lineColor: Int + private var lineStrokeWidth: Float + private var cellHeight: Int = 0 + private var textSize: Float + private var textColor: Int + private var showSize: Int + private var listener: ((Int) -> Unit)? = null + var drawListener: DrawListener? = null + + init { + setWillNotDraw(false) + val it = context.obtainStyledAttributes(attrs, R.styleable.SimplePickerView) + showSize = it.getInt(R.styleable.SimplePickerView_spvShowSize, 5) + if (showSize % 2 == 0 || showSize < 3) throw Throwable("dpvDateSize value must be odd number and must be bigger than 2") + textColor = it.getColor(R.styleable.SimplePickerView_spvTextColor, Color.BLACK) + + lineStrokeWidth = it.getDimension( + R.styleable.SimplePickerView_spvLineWidth, + resources.displayMetrics.density + ) + lineColor = it.getColor(R.styleable.SimplePickerView_spvLineColor, Color.TRANSPARENT) + backColor = it.getColor(R.styleable.SimplePickerView_spvBackgroundColor, Color.TRANSPARENT) + textSize = it.getDimension( + R.styleable.SimplePickerView_spvTextSize, + 18 * resources.displayMetrics.density + ) + padTop = it.getDimension(R.styleable.SimplePickerView_spvPaddingTop, 0f) + it.recycle() + + addOnScrollListener(object : OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + listener?.invoke((recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition()) + } + + }) + } + + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + super.onMeasure(widthSpec, heightSpec) + val sizeHeight = MeasureSpec.getSize(heightSpec) + cellHeight = sizeHeight / showSize + setMeasuredDimension(MeasureSpec.getSize(widthSpec), cellHeight * showSize) + (adapter as? SimplePickerAdapter)?.itemHeight = cellHeight + measureChildren(widthSpec, heightSpec) + } + + /** + * 设置数据 + */ + fun setData(list: List, position: Int, scrollBack: ((Int) -> Unit)? = null) { + post { + listener = scrollBack + adapter = SimplePickerAdapter(list, showSize, textColor, textSize, cellHeight) + val p = if (position < 0) 0 else position + scrollToPosition(p) + scrollBack?.invoke(p) + } + } + + /** + * 获取当前选择数据 + */ + val currentValue: String + get() { + val po = (layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + if (po == NO_POSITION) + return "" + return (adapter as SimplePickerAdapter).array[po] + } + + private val paint = Paint() + private val rectF = RectF() + override fun onDraw(canvas: Canvas) { + canvas?.translate(0f, padTop) + drawListener?.drawBelow(canvas, measuredWidth, measuredHeight, cellHeight) + rectF.set( + 0f, + (measuredHeight - cellHeight) / 2f, + measuredWidth / 1f, + (measuredHeight + cellHeight) / 2f + ) + paint.reset() + paint.isAntiAlias = true + paint.strokeWidth = lineStrokeWidth + paint.color = backColor + paint.style = Paint.Style.FILL + canvas.drawRoundRect (rectF,20f,20f, paint) + + paint.color = lineColor + paint.style = Paint.Style.STROKE + canvas.drawLine(rectF.left, rectF.top, rectF.right, rectF.top, paint) + canvas.drawLine(rectF.left, rectF.bottom, rectF.right, rectF.bottom, paint) + super.onDraw(canvas) + + drawListener?.drawOver(canvas, measuredWidth, measuredHeight, cellHeight) + } + + + class SimplePickerAdapter( + val array: List, + val showSize: Int, + val textcolor: Int, + val textsize: Float, + var itemHeight: Int + ) : + Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return object : ViewHolder(TextView(parent.context).apply { + layoutParams = LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, itemHeight) + gravity = Gravity.CENTER + setTextColor(textcolor) + setTextSize(TypedValue.COMPLEX_UNIT_PX, textsize) + }) {} + } + + override fun getItemCount(): Int { + return array.size + showSize - 1 + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + (holder.itemView as TextView).text = when (position) { + in (0 until showSize / 2) -> "" + in itemCount - showSize / 2 until itemCount -> "" + else -> array[position - showSize / 2] + } + } + } + +} \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg.png b/composeApp/src/androidMain/res/drawable/bg.png new file mode 100644 index 0000000..30d562f Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_card_exercise_history.xml b/composeApp/src/androidMain/res/drawable/bg_card_exercise_history.xml new file mode 100644 index 0000000..44d2704 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_card_exercise_history.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_card_hrv.xml b/composeApp/src/androidMain/res/drawable/bg_card_hrv.xml new file mode 100644 index 0000000..99093d6 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_card_hrv.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_card_recovery.xml b/composeApp/src/androidMain/res/drawable/bg_card_recovery.xml new file mode 100644 index 0000000..5253b74 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_card_recovery.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_card_recovery_linechart.png b/composeApp/src/androidMain/res/drawable/bg_card_recovery_linechart.png new file mode 100644 index 0000000..41abbf0 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_card_recovery_linechart.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_card_recovery_tip.xml b/composeApp/src/androidMain/res/drawable/bg_card_recovery_tip.xml new file mode 100644 index 0000000..b02a118 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_card_recovery_tip.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_card_sleep.xml b/composeApp/src/androidMain/res/drawable/bg_card_sleep.xml new file mode 100644 index 0000000..f133b1e --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_card_sleep.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_card_sleep_recovery_state.xml b/composeApp/src/androidMain/res/drawable/bg_card_sleep_recovery_state.xml new file mode 100644 index 0000000..6204ee7 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_card_sleep_recovery_state.xml @@ -0,0 +1,12 @@ + + + + diff --git a/composeApp/src/androidMain/res/drawable/bg_card_target.xml b/composeApp/src/androidMain/res/drawable/bg_card_target.xml new file mode 100644 index 0000000..faf3c0d --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_card_target.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_date.xml b/composeApp/src/androidMain/res/drawable/bg_date.xml new file mode 100644 index 0000000..65c3420 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_date.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_date_selector.xml b/composeApp/src/androidMain/res/drawable/bg_date_selector.xml new file mode 100644 index 0000000..4ec0d89 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_date_selector.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_dialog_connect_error.png b/composeApp/src/androidMain/res/drawable/bg_dialog_connect_error.png new file mode 100644 index 0000000..4c2855d Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_dialog_connect_error.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_evaluate.xml b/composeApp/src/androidMain/res/drawable/bg_evaluate.xml new file mode 100644 index 0000000..30d3f6f --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_evaluate.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/bg_exercise.png b/composeApp/src/androidMain/res/drawable/bg_exercise.png new file mode 100644 index 0000000..1abedd4 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_exercise.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_exercise_recode.png b/composeApp/src/androidMain/res/drawable/bg_exercise_recode.png new file mode 100644 index 0000000..103cdfd Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_exercise_recode.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_hold.xml b/composeApp/src/androidMain/res/drawable/bg_hold.xml new file mode 100644 index 0000000..3c61112 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_hold.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/bg_hrv.png b/composeApp/src/androidMain/res/drawable/bg_hrv.png new file mode 100644 index 0000000..6e6b063 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_hrv.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_launcher_button.xml b/composeApp/src/androidMain/res/drawable/bg_launcher_button.xml new file mode 100644 index 0000000..d106f9e --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_launcher_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/androidMain/res/drawable/bg_launcher_page.xml b/composeApp/src/androidMain/res/drawable/bg_launcher_page.xml new file mode 100644 index 0000000..810d9a7 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_launcher_page.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/bg_line_gradient.xml b/composeApp/src/androidMain/res/drawable/bg_line_gradient.xml new file mode 100644 index 0000000..fab9c47 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_line_gradient.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_login_button.xml b/composeApp/src/androidMain/res/drawable/bg_login_button.xml new file mode 100644 index 0000000..a6e711f --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_login_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_10.xml b/composeApp/src/androidMain/res/drawable/bg_radius_10.xml new file mode 100644 index 0000000..0900e48 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_10.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_15.xml b/composeApp/src/androidMain/res/drawable/bg_radius_15.xml new file mode 100644 index 0000000..42023b1 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_15.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_16.xml b/composeApp/src/androidMain/res/drawable/bg_radius_16.xml new file mode 100644 index 0000000..819d221 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_16.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_20.xml b/composeApp/src/androidMain/res/drawable/bg_radius_20.xml new file mode 100644 index 0000000..bf54e7e --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_20.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_20_board.xml b/composeApp/src/androidMain/res/drawable/bg_radius_20_board.xml new file mode 100644 index 0000000..94f7dcb --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_20_board.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_24.xml b/composeApp/src/androidMain/res/drawable/bg_radius_24.xml new file mode 100644 index 0000000..d3917fe --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_24.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_27_5.xml b/composeApp/src/androidMain/res/drawable/bg_radius_27_5.xml new file mode 100644 index 0000000..9bb9f87 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_27_5.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_30.xml b/composeApp/src/androidMain/res/drawable/bg_radius_30.xml new file mode 100644 index 0000000..d267bfe --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_30.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_40.xml b/composeApp/src/androidMain/res/drawable/bg_radius_40.xml new file mode 100644 index 0000000..3bbbde5 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_40.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_radius_top_15.xml b/composeApp/src/androidMain/res/drawable/bg_radius_top_15.xml new file mode 100644 index 0000000..970fcc8 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_radius_top_15.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/bg_recovery.png b/composeApp/src/androidMain/res/drawable/bg_recovery.png new file mode 100644 index 0000000..ec613ce Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_recovery.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_sleep_type_day.png b/composeApp/src/androidMain/res/drawable/bg_sleep_type_day.png new file mode 100644 index 0000000..2401f7f Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_sleep_type_day.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_target.png b/composeApp/src/androidMain/res/drawable/bg_target.png new file mode 100644 index 0000000..60f8bc1 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/bg_target.png differ diff --git a/composeApp/src/androidMain/res/drawable/bg_text_container.xml b/composeApp/src/androidMain/res/drawable/bg_text_container.xml new file mode 100644 index 0000000..6a1a499 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/bg_text_container.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/block_hand_left.png b/composeApp/src/androidMain/res/drawable/block_hand_left.png new file mode 100644 index 0000000..1cd182c Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/block_hand_left.png differ diff --git a/composeApp/src/androidMain/res/drawable/block_hand_right.png b/composeApp/src/androidMain/res/drawable/block_hand_right.png new file mode 100644 index 0000000..8a432f7 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/block_hand_right.png differ diff --git a/composeApp/src/androidMain/res/drawable/connect_icon.png b/composeApp/src/androidMain/res/drawable/connect_icon.png new file mode 100644 index 0000000..44a66eb Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/connect_icon.png differ diff --git a/composeApp/src/androidMain/res/drawable/connect_tip.png b/composeApp/src/androidMain/res/drawable/connect_tip.png new file mode 100644 index 0000000..1ced4d2 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/connect_tip.png differ diff --git a/composeApp/src/androidMain/res/drawable/dashed_line.xml b/composeApp/src/androidMain/res/drawable/dashed_line.xml new file mode 100644 index 0000000..3bc510d --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/dashed_line.xml @@ -0,0 +1,8 @@ + + + diff --git a/composeApp/src/androidMain/res/drawable/exercise_add.png b/composeApp/src/androidMain/res/drawable/exercise_add.png new file mode 100644 index 0000000..67472dc Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/exercise_add.png differ diff --git a/composeApp/src/androidMain/res/drawable/exercise_distance.png b/composeApp/src/androidMain/res/drawable/exercise_distance.png new file mode 100644 index 0000000..e718593 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/exercise_distance.png differ diff --git a/composeApp/src/androidMain/res/drawable/exercise_free.png b/composeApp/src/androidMain/res/drawable/exercise_free.png new file mode 100644 index 0000000..eff7549 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/exercise_free.png differ diff --git a/composeApp/src/androidMain/res/drawable/exercise_heat.png b/composeApp/src/androidMain/res/drawable/exercise_heat.png new file mode 100644 index 0000000..d17bd4b Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/exercise_heat.png differ diff --git a/composeApp/src/androidMain/res/drawable/exercise_history_free.png b/composeApp/src/androidMain/res/drawable/exercise_history_free.png new file mode 100644 index 0000000..7486476 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/exercise_history_free.png differ diff --git a/composeApp/src/androidMain/res/drawable/exercise_history_running.png b/composeApp/src/androidMain/res/drawable/exercise_history_running.png new file mode 100644 index 0000000..881f9a4 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/exercise_history_running.png differ diff --git a/composeApp/src/androidMain/res/drawable/exercise_time.png b/composeApp/src/androidMain/res/drawable/exercise_time.png new file mode 100644 index 0000000..50446d9 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/exercise_time.png differ diff --git a/composeApp/src/androidMain/res/drawable/gradient_white_to_transparent.xml b/composeApp/src/androidMain/res/drawable/gradient_white_to_transparent.xml new file mode 100644 index 0000000..8eaa62b --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/gradient_white_to_transparent.xml @@ -0,0 +1,11 @@ + + + + diff --git a/composeApp/src/androidMain/res/drawable/handedness_selected_left.png b/composeApp/src/androidMain/res/drawable/handedness_selected_left.png new file mode 100644 index 0000000..b0d23d4 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/handedness_selected_left.png differ diff --git a/composeApp/src/androidMain/res/drawable/handedness_selected_right.png b/composeApp/src/androidMain/res/drawable/handedness_selected_right.png new file mode 100644 index 0000000..71d5124 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/handedness_selected_right.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_ani_beer.png b/composeApp/src/androidMain/res/drawable/ic_ani_beer.png new file mode 100644 index 0000000..2a548ea Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_ani_beer.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_arrow_right.png b/composeApp/src/androidMain/res/drawable/ic_arrow_right.png new file mode 100644 index 0000000..7633fbf Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_arrow_right.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_arrow_right_animal.png b/composeApp/src/androidMain/res/drawable/ic_arrow_right_animal.png new file mode 100644 index 0000000..7834243 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_arrow_right_animal.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_battery.png b/composeApp/src/androidMain/res/drawable/ic_battery.png new file mode 100644 index 0000000..28e1a4c Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_battery.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_biological_clock.png b/composeApp/src/androidMain/res/drawable/ic_biological_clock.png new file mode 100644 index 0000000..3dc9f81 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_biological_clock.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_circle_indicator.xml b/composeApp/src/androidMain/res/drawable/ic_circle_indicator.xml new file mode 100644 index 0000000..2933e90 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_circle_indicator.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/ic_clock.png b/composeApp/src/androidMain/res/drawable/ic_clock.png new file mode 100644 index 0000000..a4e9cd0 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_clock.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_close.png b/composeApp/src/androidMain/res/drawable/ic_close.png new file mode 100644 index 0000000..b29a014 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_close.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_connect_mutiple.png b/composeApp/src/androidMain/res/drawable/ic_connect_mutiple.png new file mode 100644 index 0000000..6221c3b Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_connect_mutiple.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_find.png b/composeApp/src/androidMain/res/drawable/ic_find.png new file mode 100644 index 0000000..4f57d3a Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_find.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_finder.png b/composeApp/src/androidMain/res/drawable/ic_finder.png new file mode 100644 index 0000000..d7abd24 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_finder.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_heart.xml b/composeApp/src/androidMain/res/drawable/ic_heart.xml new file mode 100644 index 0000000..0300d2e --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_heart.xml @@ -0,0 +1,20 @@ + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_help.png b/composeApp/src/androidMain/res/drawable/ic_help.png new file mode 100644 index 0000000..3a4c360 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_help.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/ic_loading.png b/composeApp/src/androidMain/res/drawable/ic_loading.png new file mode 100644 index 0000000..d74bc10 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_loading.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_message.png b/composeApp/src/androidMain/res/drawable/ic_message.png new file mode 100644 index 0000000..8d3234b Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_message.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_nav_exerices.png b/composeApp/src/androidMain/res/drawable/ic_nav_exerices.png new file mode 100644 index 0000000..b782276 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_nav_exerices.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_nav_recovery.png b/composeApp/src/androidMain/res/drawable/ic_nav_recovery.png new file mode 100644 index 0000000..73f33a5 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_nav_recovery.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_nav_setting.png b/composeApp/src/androidMain/res/drawable/ic_nav_setting.png new file mode 100644 index 0000000..78fb772 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_nav_setting.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_nav_state.png b/composeApp/src/androidMain/res/drawable/ic_nav_state.png new file mode 100644 index 0000000..b830207 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_nav_state.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_notify.png b/composeApp/src/androidMain/res/drawable/ic_notify.png new file mode 100644 index 0000000..16032dc Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_notify.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_oxygen.xml b/composeApp/src/androidMain/res/drawable/ic_oxygen.xml new file mode 100644 index 0000000..8dd2a30 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_oxygen.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_percentage_tip.xml b/composeApp/src/androidMain/res/drawable/ic_percentage_tip.xml new file mode 100644 index 0000000..3b9bb9b --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_percentage_tip.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_pressure.xml b/composeApp/src/androidMain/res/drawable/ic_pressure.xml new file mode 100644 index 0000000..1ec4e7b --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_pressure.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_running.png b/composeApp/src/androidMain/res/drawable/ic_running.png new file mode 100644 index 0000000..daa512b Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_running.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_running_back.png b/composeApp/src/androidMain/res/drawable/ic_running_back.png new file mode 100644 index 0000000..1d12c11 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_running_back.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_running_pause.png b/composeApp/src/androidMain/res/drawable/ic_running_pause.png new file mode 100644 index 0000000..331e7f5 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_running_pause.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_selected_day.png b/composeApp/src/androidMain/res/drawable/ic_selected_day.png new file mode 100644 index 0000000..5c67f24 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_selected_day.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_share.png b/composeApp/src/androidMain/res/drawable/ic_share.png new file mode 100644 index 0000000..94f0a1a Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_share.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_sleep.xml b/composeApp/src/androidMain/res/drawable/ic_sleep.xml new file mode 100644 index 0000000..fd81bad --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_sleep.xml @@ -0,0 +1,19 @@ + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_sleep_description.png b/composeApp/src/androidMain/res/drawable/ic_sleep_description.png new file mode 100644 index 0000000..2695510 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_sleep_description.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_sleep_type_date.png b/composeApp/src/androidMain/res/drawable/ic_sleep_type_date.png new file mode 100644 index 0000000..9849b4e Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_sleep_type_date.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_start_empty.png b/composeApp/src/androidMain/res/drawable/ic_start_empty.png new file mode 100644 index 0000000..d92a0a7 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_start_empty.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_start_full.png b/composeApp/src/androidMain/res/drawable/ic_start_full.png new file mode 100644 index 0000000..587e16c Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_start_full.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_temperature.xml b/composeApp/src/androidMain/res/drawable/ic_temperature.xml new file mode 100644 index 0000000..9acb2f5 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_temperature.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_tip.png b/composeApp/src/androidMain/res/drawable/ic_tip.png new file mode 100644 index 0000000..bc856c2 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_tip.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_wallet.png b/composeApp/src/androidMain/res/drawable/ic_wallet.png new file mode 100644 index 0000000..7e0ee88 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_wallet.png differ diff --git a/composeApp/src/androidMain/res/drawable/ic_wechat.png b/composeApp/src/androidMain/res/drawable/ic_wechat.png new file mode 100644 index 0000000..df9685d Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ic_wechat.png differ diff --git a/composeApp/src/androidMain/res/drawable/icon_clock.png b/composeApp/src/androidMain/res/drawable/icon_clock.png new file mode 100644 index 0000000..07e6a5e Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/icon_clock.png differ diff --git a/composeApp/src/androidMain/res/drawable/img.png b/composeApp/src/androidMain/res/drawable/img.png new file mode 100644 index 0000000..0c65a1b Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/img.png differ diff --git a/composeApp/src/androidMain/res/drawable/item_checkbox_selected.png b/composeApp/src/androidMain/res/drawable/item_checkbox_selected.png new file mode 100644 index 0000000..4508bf9 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/item_checkbox_selected.png differ diff --git a/composeApp/src/androidMain/res/drawable/item_checkbox_unselected.png b/composeApp/src/androidMain/res/drawable/item_checkbox_unselected.png new file mode 100644 index 0000000..019ad5a Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/item_checkbox_unselected.png differ diff --git a/composeApp/src/androidMain/res/drawable/item_radius_selected.png b/composeApp/src/androidMain/res/drawable/item_radius_selected.png new file mode 100644 index 0000000..49cb79a Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/item_radius_selected.png differ diff --git a/composeApp/src/androidMain/res/drawable/item_radius_unselect.png b/composeApp/src/androidMain/res/drawable/item_radius_unselect.png new file mode 100644 index 0000000..83a52d1 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/item_radius_unselect.png differ diff --git a/composeApp/src/androidMain/res/drawable/line_hand_left.png b/composeApp/src/androidMain/res/drawable/line_hand_left.png new file mode 100644 index 0000000..01bb965 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/line_hand_left.png differ diff --git a/composeApp/src/androidMain/res/drawable/line_hand_right.png b/composeApp/src/androidMain/res/drawable/line_hand_right.png new file mode 100644 index 0000000..9748dca Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/line_hand_right.png differ diff --git a/composeApp/src/androidMain/res/drawable/login_bg.png b/composeApp/src/androidMain/res/drawable/login_bg.png new file mode 100644 index 0000000..4b1a5d9 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/login_bg.png differ diff --git a/composeApp/src/androidMain/res/drawable/riding.png b/composeApp/src/androidMain/res/drawable/riding.png new file mode 100644 index 0000000..5de6d9a Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/riding.png differ diff --git a/composeApp/src/androidMain/res/drawable/ring_icon.png b/composeApp/src/androidMain/res/drawable/ring_icon.png new file mode 100644 index 0000000..322bc97 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/ring_icon.png differ diff --git a/composeApp/src/androidMain/res/drawable/running.png b/composeApp/src/androidMain/res/drawable/running.png new file mode 100644 index 0000000..c805f20 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/running.png differ diff --git a/composeApp/src/androidMain/res/drawable/running_bg.png b/composeApp/src/androidMain/res/drawable/running_bg.png new file mode 100644 index 0000000..e09955a Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/running_bg.png differ diff --git a/composeApp/src/androidMain/res/drawable/running_distance_bg.png b/composeApp/src/androidMain/res/drawable/running_distance_bg.png new file mode 100644 index 0000000..fc048fc Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/running_distance_bg.png differ diff --git a/composeApp/src/androidMain/res/drawable/selector_date_tab.xml b/composeApp/src/androidMain/res/drawable/selector_date_tab.xml new file mode 100644 index 0000000..5b2a1a0 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/selector_date_tab.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/sleep_detail_bg.png b/composeApp/src/androidMain/res/drawable/sleep_detail_bg.png new file mode 100644 index 0000000..46d5400 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/sleep_detail_bg.png differ diff --git a/composeApp/src/androidMain/res/drawable/sleep_detail_date_bg.png b/composeApp/src/androidMain/res/drawable/sleep_detail_date_bg.png new file mode 100644 index 0000000..ce8d842 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/sleep_detail_date_bg.png differ diff --git a/composeApp/src/androidMain/res/drawable/swimming.png b/composeApp/src/androidMain/res/drawable/swimming.png new file mode 100644 index 0000000..51a5d2b Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/swimming.png differ diff --git a/composeApp/src/androidMain/res/drawable/tab_indicator.xml b/composeApp/src/androidMain/res/drawable/tab_indicator.xml new file mode 100644 index 0000000..2071e63 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/tab_indicator.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/welcome_bg.png b/composeApp/src/androidMain/res/drawable/welcome_bg.png new file mode 100644 index 0000000..a190818 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/welcome_bg.png differ diff --git a/composeApp/src/androidMain/res/drawable/welcome_icon.png b/composeApp/src/androidMain/res/drawable/welcome_icon.png new file mode 100644 index 0000000..4308f46 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/welcome_icon.png differ diff --git a/composeApp/src/androidMain/res/drawable/welcome_icon_big.png b/composeApp/src/androidMain/res/drawable/welcome_icon_big.png new file mode 100644 index 0000000..3c0ff99 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/welcome_icon_big.png differ diff --git a/composeApp/src/androidMain/res/layout/activity_connect.xml b/composeApp/src/androidMain/res/layout/activity_connect.xml new file mode 100644 index 0000000..3332ee1 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_connect.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_devices.xml b/composeApp/src/androidMain/res/layout/activity_devices.xml new file mode 100644 index 0000000..f794f39 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_devices.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_home.xml b/composeApp/src/androidMain/res/layout/activity_home.xml new file mode 100644 index 0000000..f4cd058 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_home.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_hrv_assessment.xml b/composeApp/src/androidMain/res/layout/activity_hrv_assessment.xml new file mode 100644 index 0000000..2f72761 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_hrv_assessment.xml @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/layout/activity_launcher.xml b/composeApp/src/androidMain/res/layout/activity_launcher.xml new file mode 100644 index 0000000..e21abeb --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_launcher.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_login.xml b/composeApp/src/androidMain/res/layout/activity_login.xml new file mode 100644 index 0000000..1cd55d5 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_login.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_plan.xml b/composeApp/src/androidMain/res/layout/activity_plan.xml new file mode 100644 index 0000000..500abab --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_plan.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_preference.xml b/composeApp/src/androidMain/res/layout/activity_preference.xml new file mode 100644 index 0000000..b65c7c5 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_preference.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_running.xml b/composeApp/src/androidMain/res/layout/activity_running.xml new file mode 100644 index 0000000..a3fb9fd --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_running.xml @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_running_hint.xml b/composeApp/src/androidMain/res/layout/activity_running_hint.xml new file mode 100644 index 0000000..cdda290 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_running_hint.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_sleep.xml b/composeApp/src/androidMain/res/layout/activity_sleep.xml new file mode 100644 index 0000000..c5d2f68 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_sleep.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_sleep_detail.xml b/composeApp/src/androidMain/res/layout/activity_sleep_detail.xml new file mode 100644 index 0000000..9e3fca1 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_sleep_detail.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/activity_sleep_type.xml b/composeApp/src/androidMain/res/layout/activity_sleep_type.xml new file mode 100644 index 0000000..9018803 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_sleep_type.xml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/layout/activity_user_info.xml b/composeApp/src/androidMain/res/layout/activity_user_info.xml new file mode 100644 index 0000000..d7258ce --- /dev/null +++ b/composeApp/src/androidMain/res/layout/activity_user_info.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_animal_type.xml b/composeApp/src/androidMain/res/layout/card_animal_type.xml new file mode 100644 index 0000000..e348ab1 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_animal_type.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_bar_chart_small.xml b/composeApp/src/androidMain/res/layout/card_bar_chart_small.xml new file mode 100644 index 0000000..09f2ffb --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_bar_chart_small.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_biological_clock.xml b/composeApp/src/androidMain/res/layout/card_biological_clock.xml new file mode 100644 index 0000000..106f6c0 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_biological_clock.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_blood_oxygen_full_row.xml b/composeApp/src/androidMain/res/layout/card_blood_oxygen_full_row.xml new file mode 100644 index 0000000..b11fbd5 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_blood_oxygen_full_row.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_evaluate.xml b/composeApp/src/androidMain/res/layout/card_evaluate.xml new file mode 100644 index 0000000..94b5c01 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_evaluate.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_exercise_history.xml b/composeApp/src/androidMain/res/layout/card_exercise_history.xml new file mode 100644 index 0000000..3d0eb00 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_exercise_history.xml @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_exercise_target.xml b/composeApp/src/androidMain/res/layout/card_exercise_target.xml new file mode 100644 index 0000000..9ff5f98 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_exercise_target.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_foot.xml b/composeApp/src/androidMain/res/layout/card_foot.xml new file mode 100644 index 0000000..645bef9 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_foot.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_heart_health.xml b/composeApp/src/androidMain/res/layout/card_heart_health.xml new file mode 100644 index 0000000..7727426 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_heart_health.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_heart_rate_full_row.xml b/composeApp/src/androidMain/res/layout/card_heart_rate_full_row.xml new file mode 100644 index 0000000..e10e3a6 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_heart_rate_full_row.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_heart_rate_half_row.xml b/composeApp/src/androidMain/res/layout/card_heart_rate_half_row.xml new file mode 100644 index 0000000..3bb7a6e --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_heart_rate_half_row.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_hrv.xml b/composeApp/src/androidMain/res/layout/card_hrv.xml new file mode 100644 index 0000000..f808f11 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_hrv.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_recovery_line_chart.xml b/composeApp/src/androidMain/res/layout/card_recovery_line_chart.xml new file mode 100644 index 0000000..d4c4876 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_recovery_line_chart.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_recovery_score.xml b/composeApp/src/androidMain/res/layout/card_recovery_score.xml new file mode 100644 index 0000000..c4edd4a --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_recovery_score.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_recovery_state_list.xml b/composeApp/src/androidMain/res/layout/card_recovery_state_list.xml new file mode 100644 index 0000000..3fc3b24 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_recovery_state_list.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_recovery_transprant.xml b/composeApp/src/androidMain/res/layout/card_recovery_transprant.xml new file mode 100644 index 0000000..fee3d9d --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_recovery_transprant.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_sleep_detail.xml b/composeApp/src/androidMain/res/layout/card_sleep_detail.xml new file mode 100644 index 0000000..c6e13bd --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_sleep_detail.xml @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_sleep_recovery_state.xml b/composeApp/src/androidMain/res/layout/card_sleep_recovery_state.xml new file mode 100644 index 0000000..25bc212 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_sleep_recovery_state.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_sleep_state.xml b/composeApp/src/androidMain/res/layout/card_sleep_state.xml new file mode 100644 index 0000000..77ee256 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_sleep_state.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_temperature_line_chart_full_row.xml b/composeApp/src/androidMain/res/layout/card_temperature_line_chart_full_row.xml new file mode 100644 index 0000000..2fb52b9 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_temperature_line_chart_full_row.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/card_tips.xml b/composeApp/src/androidMain/res/layout/card_tips.xml new file mode 100644 index 0000000..bb2a20c --- /dev/null +++ b/composeApp/src/androidMain/res/layout/card_tips.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/dialog_add_exercise.xml b/composeApp/src/androidMain/res/layout/dialog_add_exercise.xml new file mode 100644 index 0000000..4542a39 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/dialog_add_exercise.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/dialog_connect_error.xml b/composeApp/src/androidMain/res/layout/dialog_connect_error.xml new file mode 100644 index 0000000..60dbb92 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/dialog_connect_error.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/dialog_exercise.xml b/composeApp/src/androidMain/res/layout/dialog_exercise.xml new file mode 100644 index 0000000..f13abd5 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/dialog_exercise.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/dialog_select.xml b/composeApp/src/androidMain/res/layout/dialog_select.xml new file mode 100644 index 0000000..0e2e53c --- /dev/null +++ b/composeApp/src/androidMain/res/layout/dialog_select.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/dialog_set_target.xml b/composeApp/src/androidMain/res/layout/dialog_set_target.xml new file mode 100644 index 0000000..7292c08 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/dialog_set_target.xml @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_exercise.xml b/composeApp/src/androidMain/res/layout/fragment_exercise.xml new file mode 100644 index 0000000..e7b8b9f --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_exercise.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_handedness.xml b/composeApp/src/androidMain/res/layout/fragment_handedness.xml new file mode 100644 index 0000000..7523500 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_handedness.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_like.xml b/composeApp/src/androidMain/res/layout/fragment_like.xml new file mode 100644 index 0000000..911f8b4 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_like.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_recovery.xml b/composeApp/src/androidMain/res/layout/fragment_recovery.xml new file mode 100644 index 0000000..7895334 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_recovery.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_remind.xml b/composeApp/src/androidMain/res/layout/fragment_remind.xml new file mode 100644 index 0000000..f4f14cf --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_remind.xml @@ -0,0 +1,42 @@ + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_select_radius_list.xml b/composeApp/src/androidMain/res/layout/fragment_select_radius_list.xml new file mode 100644 index 0000000..0becd2d --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_select_radius_list.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_setting.xml b/composeApp/src/androidMain/res/layout/fragment_setting.xml new file mode 100644 index 0000000..073d033 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_setting.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_state.xml b/composeApp/src/androidMain/res/layout/fragment_state.xml new file mode 100644 index 0000000..7cae2c0 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_state.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_user_info.xml b/composeApp/src/androidMain/res/layout/fragment_user_info.xml new file mode 100644 index 0000000..aad30d2 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_user_info.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_wear.xml b/composeApp/src/androidMain/res/layout/fragment_wear.xml new file mode 100644 index 0000000..16d1a9d --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_wear.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/fragment_weight.xml b/composeApp/src/androidMain/res/layout/fragment_weight.xml new file mode 100644 index 0000000..3f5e7ef --- /dev/null +++ b/composeApp/src/androidMain/res/layout/fragment_weight.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/item_recovery_state.xml b/composeApp/src/androidMain/res/layout/item_recovery_state.xml new file mode 100644 index 0000000..0ab7285 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/item_recovery_state.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/item_sleep_type_date.xml b/composeApp/src/androidMain/res/layout/item_sleep_type_date.xml new file mode 100644 index 0000000..e457b51 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/item_sleep_type_date.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/layout/list_item_device.xml b/composeApp/src/androidMain/res/layout/list_item_device.xml new file mode 100644 index 0000000..178645d --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_device.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_exercise.xml b/composeApp/src/androidMain/res/layout/list_item_exercise.xml new file mode 100644 index 0000000..323e10e --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_exercise.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_like.xml b/composeApp/src/androidMain/res/layout/list_item_like.xml new file mode 100644 index 0000000..50c5c06 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_like.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_recovery_date.xml b/composeApp/src/androidMain/res/layout/list_item_recovery_date.xml new file mode 100644 index 0000000..469be93 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_recovery_date.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_select_radius.xml b/composeApp/src/androidMain/res/layout/list_item_select_radius.xml new file mode 100644 index 0000000..9af6983 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_select_radius.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_setting_full_row_block.xml b/composeApp/src/androidMain/res/layout/list_item_setting_full_row_block.xml new file mode 100644 index 0000000..64cc0cc --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_setting_full_row_block.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_setting_full_row_line.xml b/composeApp/src/androidMain/res/layout/list_item_setting_full_row_line.xml new file mode 100644 index 0000000..d72ac3c --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_setting_full_row_line.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_setting_half_row_block.xml b/composeApp/src/androidMain/res/layout/list_item_setting_half_row_block.xml new file mode 100644 index 0000000..a1e8d57 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_setting_half_row_block.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_setting_title.xml b/composeApp/src/androidMain/res/layout/list_item_setting_title.xml new file mode 100644 index 0000000..3f68aed --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_setting_title.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/layout/list_item_target_value.xml b/composeApp/src/androidMain/res/layout/list_item_target_value.xml new file mode 100644 index 0000000..18818d4 --- /dev/null +++ b/composeApp/src/androidMain/res/layout/list_item_target_value.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/androidMain/res/values/arrays.xml b/composeApp/src/androidMain/res/values/arrays.xml new file mode 100644 index 0000000..11113e4 --- /dev/null +++ b/composeApp/src/androidMain/res/values/arrays.xml @@ -0,0 +1,32 @@ + + + + File Manager + Total Commander + Drive + Search for others + + + + market://details?id=files.fileexplorer.filemanager + market://details?id=com.ghisler.android.TotalCommander + market://details?id=com.google.android.apps.docs + market://search?q=file manager + + + + 15 + 30 + 45 + 60 + 120 + 180 + + + 10 + 30 + 60 + 120 + 180 + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/attrs.xml b/composeApp/src/androidMain/res/values/attrs.xml new file mode 100644 index 0000000..28513e9 --- /dev/null +++ b/composeApp/src/androidMain/res/values/attrs.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/colors.xml b/composeApp/src/androidMain/res/values/colors.xml new file mode 100644 index 0000000..3235f60 --- /dev/null +++ b/composeApp/src/androidMain/res/values/colors.xml @@ -0,0 +1,30 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #484848 + #80484848 + #636363 + #F4F3EF + + #b6b5ba + #FF00b1a9 + #FF00a2ff + #FF117df7 + #FF3fbfff + + #72B1D4 + #6ECFB7 + #FF5A5A + #66FFFFFF + #00FFFFFF + #66FFFFFF + #fff + #80FFFFFF + #40FFFFFF + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/configs.xml b/composeApp/src/androidMain/res/values/configs.xml new file mode 100644 index 0000000..fecc067 --- /dev/null +++ b/composeApp/src/androidMain/res/values/configs.xml @@ -0,0 +1,4 @@ + + + 0 + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/dimens.xml b/composeApp/src/androidMain/res/values/dimens.xml new file mode 100644 index 0000000..b7365ef --- /dev/null +++ b/composeApp/src/androidMain/res/values/dimens.xml @@ -0,0 +1,18 @@ + + + 12dp + 19dp + 4dp + 16dp + 8dp + 12dp + 5dp + 18dp + 180dp + 30dp + 38dp + 7dp + 15dp\ + + 7dp + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..7731ff9 --- /dev/null +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,224 @@ + + RingApp + + Acti + 赋能每一个动作\nEmpower Every Move + 立即使用 + 我已阅读井同意《用户隐私协议》和《用户注册协议》 + Hi,\n欢迎来到Acti + 手机号 + 请输入您的手机号 + 验证码 + 请输入验证码 + 未注册的手机号验证后自动注册登录 + 登录 + 连接您的Acti戒指 + 将您的戒指连接到充电器,并继续下一步。请确保您的手机已启用蓝牙功能。 + 下一步 + 正在搜索设备... + 附近设备 + 找不到设备? + 连接失败? + + First Fragment + Second Fragment + Next + Previous + %d 次/分 + 无数据 + + 为了可以扫描蓝牙设备,现在还需要开启位置服务。 + 您未连接戒指 + 提示 + + yyyy年MM月dd日 + yyyy年MM月 + MM月dd日 + yyyy年MM月dd日 + 今天 + 昨天 + + + 静息心率 + 步数 + 心率沉浸 + 呼吸速率 + 血氧饱和度 + 睡眠%s %s效率 + 苏醒/被打扰时间 %s + REM睡眠 %s + 浅度睡眠 %s + 深度睡眠 %s + 零星小睡 %s + %d小时%d分钟 + %d小时 + %d分钟 + 睡眠分析 + 睡眠分析%d + + 睡眠%s %s效率 + 睡眠%s + 睡眠详情 + 睡眠 + 零星小睡 + + 可用设备 + 注意:未绑定的Ring在充电时才会广播,请充电,以便APP可以扫描到。 + 连接中… + 请等待设备连接断开 + 受限模式 + 已启用受限模式,只能恢复出厂设置和自检。 + + + 其他操作 + 手指温度 + 工厂测试 + 重启 + 恢复出厂设置 + 解綁 + 关机 + 设备信息 + 设备SN + 获取PPG读数(血氧、心率) + 仅心率 + 设置PPG参数 + SpO₂测量间隔 (0, 5~360, 分钟) + SpO₂测量间隔 + 心电图 + 设置心率&体温测量时长 + 测量时长(10 ~ 180,秒) + 提交 + 请输入测量时长! + + + 戒指断开连接,设置参数无法发送。 + + + + 选择 + 查询 + 通过文件管理器APP从本地选择固件文件 + 从服务器查询并下载新的固件到本地 + 您的设备暂未支持从服务器查询新的固件,缺少关键参数【%s】 + 升级 + 在您的设备上未找到文件浏览器应用程序。你想下载一个吗? + 该戒指为无线充电模式,请选择SR09W固件更新。 + 该戒指为NFC充电模式,请选择SR09N固件更新。 + 您的设备固件已经是最新版本:v%s. + 查询到最新的固件版本:v%s,下载中… + \nMD5匹配,正在保存文件到本地… + \nMD5不匹配!请检查。 + \n文件存储路径:%s + \n文件存储出错:%s + 查询到空内容! + 请求失败,code = %d。 + 选择此固件 + + 尺寸%d + 深黑色 + 银色 + 金色 + 玫瑰金 + 金/银混色 + 紫/银混色 + 玫瑰金/银混色 + 充电中 + 放电中 + + OEM验证失败 + 设备序列号空,连接认证失败。 + R1解密并生成R2失败。 + 连接失败,请使用凌拓NexRing智能戒指。 + 断开连接 + + 您的戒指型号不支持【锻炼模式】! + 锻炼 + 锻炼记录 + 锻炼时间 + 时间间隔 + 平均心率 + 最高心率 + 最低心率 + 同步数据中… + 没有可用的心率数据! + 选择一项锻炼 + 步行 + 室内跑步 + 室外跑步 + 室内骑车 + 室外骑车 + 山地自行车 + 游泳 + 添加详情 + 开始 + 已结束 + 进行中 + 提早结束 + 是否提早结束锻炼? + 戒指充电中,请佩戴后操作 + %d 秒 + %d 分钟 + %d 小时 + + 命令执行成功。 + 命令执行失败。 + >命令执行失败。戒指连接时OEM验证未通过。 + >命令执行失败。戒指正在主动测量。 + >命令执行失败。戒指处于锻炼模式。 + >命令执行失败。戒指正在执行APP发起的测量。 + >命令执行失败。参数错误。 + + 使用内置算法 + 输出原始波形 + 采样率 + + 请输入有效值 + + + + 您的戒指型号不支持【心电图测量】! + 开始 + 停止 + 心电图设置 + 时间基准 + 时间基准:%1$s + 增益 + 增益:%1$s + 生成PDF文件 + %d 秒 + 设备端参数设置 + "'记录时间:'yyyy年MM月dd日 HH:mm" + %s, %s,导联I,512赫,Linktop NexRing + "yyyy.MM.dd HH:mm" + "'%s' yyyy-MM-dd HH_mm'.pdf'" + 正在生成PDF文件… + 请先测量一次心电图。 + 心电图PGA增益(单位:V/V) + 您的戒指现在佩戴于 + 左手指 + 右手指 + 将另一只手的手指搭在戒指上,以便形成导联并完成心电测量。 + 心率 %s BPM + 平均心率 %s BPM + 窦性心律 + 房颤 + 低心率 + 高心率 + 不确定 + 记录结果不佳 + 无结果 + + 需要权限 + 需要蓝牙权限来扫描和连接设备。请在设置中授予权限。 + 没有蓝牙权限,应用可能无法正常工作。您可以在系统设置中手动开启权限。 + + OEM 认证失败 + 设备序列号空,OEM 认证失败。 + R1 解密并生成 R2 失败。 + + 您的戒指已开启 OEM 认证,但本 APP 设定的 OEM 字符串似乎与您的戒指所写入的不匹配, + 请检查 Demo 工程的 `res/values/arrays.xml` 的 `oem_array` 中的字符串元素是否覆盖正确? + 修改后请重新编译工程生成新的 APK 文件,安装后重试。 + 如果仍然失败,请联系我们的技术支持。 + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml new file mode 100644 index 0000000..540980c --- /dev/null +++ b/composeApp/src/androidMain/res/values/themes.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/xml/backup_rules.xml b/composeApp/src/androidMain/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/xml/data_extraction_rules.xml b/composeApp/src/androidMain/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml new file mode 100644 index 0000000..1ffc948 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/whitefish/app/App.kt b/composeApp/src/commonMain/kotlin/com/whitefish/app/App.kt new file mode 100644 index 0000000..a602021 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/whitefish/app/App.kt @@ -0,0 +1,44 @@ +package com.whitefish.app + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +import ringappkmp.composeapp.generated.resources.Res +import ringappkmp.composeapp.generated.resources.compose_multiplatform + +@Composable +@Preview +fun App() { + MaterialTheme { + var showContent by remember { mutableStateOf(false) } + Column( + modifier = Modifier + .safeContentPadding() + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button(onClick = { showContent = !showContent }) { + Text("Click me!") + } + AnimatedVisibility(showContent) { + val greeting = remember { Greeting().greet() } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Image(painterResource(Res.drawable.compose_multiplatform), null) + Text("Compose: $greeting") + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/whitefish/app/Greeting.kt b/composeApp/src/commonMain/kotlin/com/whitefish/app/Greeting.kt new file mode 100644 index 0000000..821099f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/whitefish/app/Greeting.kt @@ -0,0 +1,9 @@ +package com.whitefish.app + +class Greeting { + private val platform = getPlatform() + + fun greet(): String { + return "Hello, ${platform.name}!" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/whitefish/app/Platform.kt b/composeApp/src/commonMain/kotlin/com/whitefish/app/Platform.kt new file mode 100644 index 0000000..21616fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/whitefish/app/Platform.kt @@ -0,0 +1,7 @@ +package com.whitefish.app + +interface Platform { + val name: String +} + +expect fun getPlatform(): Platform \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/com/whitefish/app/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/com/whitefish/app/ComposeAppCommonTest.kt new file mode 100644 index 0000000..df72c83 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/whitefish/app/ComposeAppCommonTest.kt @@ -0,0 +1,12 @@ +package com.whitefish.app + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ComposeAppCommonTest { + + @Test + fun example() { + assertEquals(3, 1 + 2) + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/whitefish/app/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/whitefish/app/MainViewController.kt new file mode 100644 index 0000000..1d7ff25 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/whitefish/app/MainViewController.kt @@ -0,0 +1,5 @@ +package com.whitefish.app + +import androidx.compose.ui.window.ComposeUIViewController + +fun MainViewController() = ComposeUIViewController { App() } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/whitefish/app/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/whitefish/app/Platform.ios.kt new file mode 100644 index 0000000..a83844d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/whitefish/app/Platform.ios.kt @@ -0,0 +1,9 @@ +package com.whitefish.app + +import platform.UIKit.UIDevice + +class IOSPlatform: Platform { + override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion +} + +actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file diff --git a/ecgAlgo/.gitignore b/ecgAlgo/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ecgAlgo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ecgAlgo/build.gradle b/ecgAlgo/build.gradle new file mode 100644 index 0000000..6343bfb --- /dev/null +++ b/ecgAlgo/build.gradle @@ -0,0 +1,41 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.ecg.algo" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + targetSdk = 35 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + minifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + main { + jniLibs.srcDirs = ['jniLibs'] + } + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') +} \ No newline at end of file diff --git a/ecgAlgo/consumer-rules.pro b/ecgAlgo/consumer-rules.pro new file mode 100644 index 0000000..fc35eb4 --- /dev/null +++ b/ecgAlgo/consumer-rules.pro @@ -0,0 +1,27 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keeppackagenames com.ecg.algo + +# Neurosky +-dontwarn com.neurosky.** +-keep class com.neurosky.** {*;} diff --git a/ecgAlgo/jniLibs/arm64-v8a/libNskAlgo.so b/ecgAlgo/jniLibs/arm64-v8a/libNskAlgo.so new file mode 100644 index 0000000..8ce2e8f Binary files /dev/null and b/ecgAlgo/jniLibs/arm64-v8a/libNskAlgo.so differ diff --git a/ecgAlgo/jniLibs/armeabi-v7a/libNskAlgo.so b/ecgAlgo/jniLibs/armeabi-v7a/libNskAlgo.so new file mode 100644 index 0000000..afc95ea Binary files /dev/null and b/ecgAlgo/jniLibs/armeabi-v7a/libNskAlgo.so differ diff --git a/ecgAlgo/jniLibs/armeabi/libNskAlgo.so b/ecgAlgo/jniLibs/armeabi/libNskAlgo.so new file mode 100644 index 0000000..332e94b Binary files /dev/null and b/ecgAlgo/jniLibs/armeabi/libNskAlgo.so differ diff --git a/ecgAlgo/jniLibs/mips/libNskAlgo.so b/ecgAlgo/jniLibs/mips/libNskAlgo.so new file mode 100644 index 0000000..361ddcc Binary files /dev/null and b/ecgAlgo/jniLibs/mips/libNskAlgo.so differ diff --git a/ecgAlgo/jniLibs/mips64/libNskAlgo.so b/ecgAlgo/jniLibs/mips64/libNskAlgo.so new file mode 100644 index 0000000..77ff5c7 Binary files /dev/null and b/ecgAlgo/jniLibs/mips64/libNskAlgo.so differ diff --git a/ecgAlgo/jniLibs/x86/libNskAlgo.so b/ecgAlgo/jniLibs/x86/libNskAlgo.so new file mode 100644 index 0000000..61ad1fd Binary files /dev/null and b/ecgAlgo/jniLibs/x86/libNskAlgo.so differ diff --git a/ecgAlgo/jniLibs/x86_64/libNskAlgo.so b/ecgAlgo/jniLibs/x86_64/libNskAlgo.so new file mode 100644 index 0000000..2881e4b Binary files /dev/null and b/ecgAlgo/jniLibs/x86_64/libNskAlgo.so differ diff --git a/ecgAlgo/libs/NskAlgoSdk.jar b/ecgAlgo/libs/NskAlgoSdk.jar new file mode 100644 index 0000000..fccfa03 Binary files /dev/null and b/ecgAlgo/libs/NskAlgoSdk.jar differ diff --git a/ecgAlgo/proguard-rules.pro b/ecgAlgo/proguard-rules.pro new file mode 100644 index 0000000..fc35eb4 --- /dev/null +++ b/ecgAlgo/proguard-rules.pro @@ -0,0 +1,27 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keeppackagenames com.ecg.algo + +# Neurosky +-dontwarn com.neurosky.** +-keep class com.neurosky.** {*;} diff --git a/ecgAlgo/src/main/java/com/ecg/algo/ECGAnalysisAlgo.kt b/ecgAlgo/src/main/java/com/ecg/algo/ECGAnalysisAlgo.kt new file mode 100644 index 0000000..6d913b9 --- /dev/null +++ b/ecgAlgo/src/main/java/com/ecg/algo/ECGAnalysisAlgo.kt @@ -0,0 +1,116 @@ +package com.ecg.algo + +import android.util.Log +import com.neurosky.AlgoSdk.NskAlgoDataType +import com.neurosky.AlgoSdk.NskAlgoECGValueType +import com.neurosky.AlgoSdk.NskAlgoSampleRate +import com.neurosky.AlgoSdk.NskAlgoSdk +import com.neurosky.AlgoSdk.NskAlgoState +import com.neurosky.AlgoSdk.NskAlgoType + +class ECGAnalysisAlgo(private val onECGAnalysisResultListener: OnECGAnalysisResultListener) { + private val sdkLicence = "NeuroSky_Release_To_GeneralFreeLicense_Use_Only_Dec 1 2016" + private val mNskAlgoSdk: NskAlgoSdk = NskAlgoSdk() + private var ecgDataIndex = 0 + private val algoTypes = intArrayOf( + NskAlgoType.NSK_ALGO_TYPE_ECG_HEARTRATE, + NskAlgoType.NSK_ALGO_TYPE_ECG_SMOOTH, + ) + + init { + mNskAlgoSdk.setOnStateChangeListener { state: Int, reason: Int -> + Log.e( + "ECGAnalysisAlgo", + "state:${NskAlgoState(state)}, reason: ${NskAlgoState(reason)}" + ) + } + var algoType = 0 + for (type in algoTypes) { + algoType = algoType or type + } + val ret = NskAlgoSdk.NskAlgoInit(algoType, "", sdkLicence) + if (ret == 0) { + Log.i( + "ECGAnalysisAlgo", + "ECG algo has been initialized successfully." + ) + setupSDK(algoType) + } else { + Log.e( + "ECGAnalysisAlgo", + "Failed to initialize ECG algo, code = $ret" + ) + } + + } + + private fun setupSDK(algoType: Int) { + if (!mNskAlgoSdk.setBaudRate( + NskAlgoDataType.NSK_ALGO_DATA_TYPE_ECG, NskAlgoSampleRate.NSK_ALGO_SAMPLE_RATE_512 + ) + ) { + Log.e( + "ECGAnalysisAlgo", + "Failed to set the sampling rate: ${NskAlgoSampleRate.NSK_ALGO_SAMPLE_RATE_512}" + ) + return + } + + var sdkVersion = "SDK ver: ${NskAlgoSdk.NskAlgoSdkVersion()}\n" + if (algoType and NskAlgoType.NSK_ALGO_TYPE_ECG_HEARTRATE != 0) { + sdkVersion += "HeartRate ver: ${NskAlgoSdk.NskAlgoAlgoVersion(NskAlgoType.NSK_ALGO_TYPE_ECG_HEARTRATE)}\n" + } + if (algoType and NskAlgoType.NSK_ALGO_TYPE_ECG_SMOOTH != 0) { + sdkVersion += "Smooth ver: ${NskAlgoSdk.NskAlgoAlgoVersion(NskAlgoType.NSK_ALGO_TYPE_ECG_SMOOTH)}\n" + } + Log.i("ECGAnalysisAlgo", "sdkVersion $sdkVersion") + + mNskAlgoSdk.setOnECGAlgoIndexListener { type: Int, value: Int -> + when (type) { + NskAlgoECGValueType.NSK_ALGO_ECG_VALUE_TYPE_SMOOTH -> { + onECGAnalysisResultListener.onOutputFilteredECGData(value) + } + NskAlgoECGValueType.NSK_ALGO_ECG_VALUE_TYPE_HR ->{ + onECGAnalysisResultListener.onOutputECGResult(value) + } + } + } + } + + fun start() { + Log.e("ECGAnalysisAlgo", "start()") + + ecgDataIndex = 0 + NskAlgoSdk.NskAlgoStart(false) + } + + fun stop() { + Log.e("ECGAnalysisAlgo", "stop()") + + NskAlgoSdk.NskAlgoPause() + NskAlgoSdk.NskAlgoStop() + } + + fun destroy() { + Log.e("ECGAnalysisAlgo", "destroy()") + + NskAlgoSdk.NskAlgoUninit() + } + + fun inputData(data: Int) { + if (ecgDataIndex == 0 || ecgDataIndex % 256 == 0) { + // send the good signal for every half second + NskAlgoSdk.NskAlgoDataStream( + NskAlgoDataType.NSK_ALGO_DATA_TYPE_ECG_PQ, + shortArrayOf(200.toShort()), + 1 + ) + } + NskAlgoSdk.NskAlgoDataStream( + NskAlgoDataType.NSK_ALGO_DATA_TYPE_ECG, + shortArrayOf(data.toShort()), + 1 + ) + ecgDataIndex++ + } +} \ No newline at end of file diff --git a/ecgAlgo/src/main/java/com/ecg/algo/OnECGAnalysisResultListener.kt b/ecgAlgo/src/main/java/com/ecg/algo/OnECGAnalysisResultListener.kt new file mode 100644 index 0000000..29286c1 --- /dev/null +++ b/ecgAlgo/src/main/java/com/ecg/algo/OnECGAnalysisResultListener.kt @@ -0,0 +1,8 @@ +package com.ecg.algo + +interface OnECGAnalysisResultListener { + + fun onOutputFilteredECGData(data: Int) + + fun onOutputECGResult(realtimeHr: Int) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6f8e6ea --- /dev/null +++ b/gradle.properties @@ -0,0 +1,12 @@ +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M + +#Gradle +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true + +#Android +android.nonTransitiveRClass=true +android.useAndroidX=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..7198eee --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,67 @@ +[versions] +agp = "8.7.3" +android-compileSdk = "35" +android-minSdk = "24" +android-targetSdk = "35" +androidx-activity = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-constraintlayout = "2.2.1" +androidx-core = "1.16.0" +androidx-espresso = "3.6.1" +androidx-lifecycle = "2.9.0" +androidx-testExt = "1.2.1" +composeMultiplatform = "1.8.1" +junit = "4.13.2" +kotlin = "2.0.21" +flexbox = "3.0.0" +fragmentKtx = "1.8.6" +glide = "4.16.0" +coreKtx = "1.13.1" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +appcompat = "1.7.0" +lifecycleViewmodelKtx = "2.8.3" +logger = "2.2.0" +material = "1.12.0" +immersionbar = "3.2.2" +mpandroidchart = "v3.1.0" +roomKtx = "2.6.1" +shadowLayout= "3.4.0" +utilcodex = "1.31.1" +androidDatabaseSqlcipher = "4.5.4" + +[libraries] +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +junit = { module = "junit:junit", version.ref = "junit" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexbox" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +logger = { module = "com.orhanobut:logger", version.ref = "logger" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +immersionbar = {group = "com.geyifeng.immersionbar", name = "immersionbar", version.ref = "immersionbar"} +immersionbar-ktx = {group = "com.geyifeng.immersionbar", name = "immersionbar-ktx", version.ref = "immersionbar"} +mpandroidchart = { module = "com.github.PhilJay:MPAndroidChart", version.ref = "mpandroidchart" } +shadowLayout = { group = "com.github.lihangleo2",name = "ShadowLayout", version.ref = "shadowLayout" } +utilcodex = { module = "com.blankj:utilcodex", version.ref = "utilcodex" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomKtx" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomKtx" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +android-database-sqlcipher = { module = "net.zetetic:android-database-sqlcipher", version.ref = "androidDatabaseSqlcipher" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig new file mode 100644 index 0000000..8ebc41c --- /dev/null +++ b/iosApp/Configuration/Config.xcconfig @@ -0,0 +1,7 @@ +TEAM_ID= + +PRODUCT_NAME=RingAppKMP +PRODUCT_BUNDLE_IDENTIFIER=com.whitefish.app.RingAppKMP$(TEAM_ID) + +CURRENT_PROJECT_VERSION=1 +MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2aed99a --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,379 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + B9DA97B12DC1472C00A4DA20 /* RingAppKMP.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RingAppKMP.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + B99700CA2DC9B8D800C7335B /* Exceptions for "iosApp" folder in "iosApp" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = B9DA97B02DC1472C00A4DA20 /* iosApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B9DA97B32DC1472C00A4DA20 /* iosApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + B99700CA2DC9B8D800C7335B /* Exceptions for "iosApp" folder in "iosApp" target */, + ); + path = iosApp; + sourceTree = ""; + }; + B9DA98002DC14AA900A4DA20 /* Configuration */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Configuration; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + B9DA97AE2DC1472C00A4DA20 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B9DA97A82DC1472C00A4DA20 = { + isa = PBXGroup; + children = ( + B9DA98002DC14AA900A4DA20 /* Configuration */, + B9DA97B32DC1472C00A4DA20 /* iosApp */, + B9DA97B22DC1472C00A4DA20 /* Products */, + ); + sourceTree = ""; + }; + B9DA97B22DC1472C00A4DA20 /* Products */ = { + isa = PBXGroup; + children = ( + B9DA97B12DC1472C00A4DA20 /* RingAppKMP.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B9DA97B02DC1472C00A4DA20 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B9DA97BF2DC1472D00A4DA20 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + B9DA97F42DC1497100A4DA20 /* Compile Kotlin Framework */, + B9DA97AD2DC1472C00A4DA20 /* Sources */, + B9DA97AE2DC1472C00A4DA20 /* Frameworks */, + B9DA97AF2DC1472C00A4DA20 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + B9DA97B32DC1472C00A4DA20 /* iosApp */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = B9DA97B12DC1472C00A4DA20 /* RingAppKMP.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B9DA97A92DC1472C00A4DA20 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + B9DA97B02DC1472C00A4DA20 = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = B9DA97AC2DC1472C00A4DA20 /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B9DA97A82DC1472C00A4DA20; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = B9DA97B22DC1472C00A4DA20 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B9DA97B02DC1472C00A4DA20 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B9DA97AF2DC1472C00A4DA20 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + B9DA97F42DC1497100A4DA20 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B9DA97AD2DC1472C00A4DA20 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + B9DA97BD2DC1472D00A4DA20 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + B9DA97BE2DC1472D00A4DA20 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B9DA97C02DC1472D00A4DA20 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B9DA98002DC14AA900A4DA20 /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B9DA97C12DC1472D00A4DA20 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B9DA98002DC14AA900A4DA20 /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B9DA97AC2DC1472C00A4DA20 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B9DA97BD2DC1472D00A4DA20 /* Debug */, + B9DA97BE2DC1472D00A4DA20 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B9DA97BF2DC1472D00A4DA20 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B9DA97C02DC1472D00A4DA20 /* Debug */, + B9DA97C12DC1472D00A4DA20 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B9DA97A92DC1472C00A4DA20 /* Project object */; +} \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4e8d485 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "app-icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png new file mode 100644 index 0000000..53fc536 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..3cd5c32 --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI +import ComposeApp + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var body: some View { + ComposeView() + .ignoresSafeArea(.keyboard) // Compose has own keyboard handler + } +} + + + diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..11845e1 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,8 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + + diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..d83dca6 --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ed0dd87 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,33 @@ +rootProject.name = "RingAppKMP" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + maven("https://jitpack.io") + mavenCentral() + } +} + +include(":composeApp") +include(":ecgAlgo") \ No newline at end of file