44 changed files with 896 additions and 350 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,19 @@ |
|||||
|
package com.whitefish.ring.data |
||||
|
|
||||
|
import androidx.datastore.core.DataStore |
||||
|
import androidx.datastore.preferences.core.Preferences |
||||
|
import com.whitefish.ring.Application |
||||
|
|
||||
|
|
||||
|
private var dataStoreInstance: DataStore<Preferences>? = null |
||||
|
private val lock = Any() |
||||
|
|
||||
|
actual fun obtainDataStore(): DataStore<Preferences> = synchronized(lock) { |
||||
|
dataStoreInstance ?: createDataStore { |
||||
|
Application.INSTANTS!!.filesDir.resolve(dataStoreFileName).absolutePath |
||||
|
}.also { dataStoreInstance = it } |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
@ -0,0 +1,29 @@ |
|||||
|
package com.whitefish.ring.data |
||||
|
|
||||
|
import com.whitefish.ring.bean.ui.HeartRate |
||||
|
import kotlinx.coroutines.Dispatchers |
||||
|
import kotlinx.coroutines.coroutineScope |
||||
|
import kotlinx.coroutines.withContext |
||||
|
import lib.linktop.nexring.api.NexRingManager |
||||
|
import kotlin.coroutines.resume |
||||
|
import kotlin.coroutines.suspendCoroutine |
||||
|
|
||||
|
|
||||
|
actual suspend fun getHeartRate( |
||||
|
start: Long, |
||||
|
end: Long |
||||
|
): List<HeartRate> = withContext(Dispatchers.IO){ |
||||
|
val macAddress = mac() // 先挂起获取mac |
||||
|
suspendCoroutine { coroutine -> |
||||
|
macAddress?.let { |
||||
|
NexRingManager.get().sleepApi().getHrList( |
||||
|
it, |
||||
|
start, |
||||
|
end, |
||||
|
) { |
||||
|
val result = it?.second?.map { HeartRate(it.ts,it.value) } |
||||
|
coroutine.resume(result?:emptyList()) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,23 +1,30 @@ |
|||||
|
@file:Suppress("ktlint:standard:no-wildcard-imports") |
||||
|
|
||||
package com.whitefish.ring |
package com.whitefish.ring |
||||
|
|
||||
import androidx.compose.material3.MaterialTheme |
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.runtime.* |
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.Modifier |
import androidx.compose.ui.Modifier |
||||
import com.whitefish.ring.ui.guide.DeviceScreen |
import com.whitefish.ring.device.IDeviceManager |
||||
import com.whitefish.ring.ui.guide.GuideNavigationScreen |
import com.whitefish.ring.ui.guide.GuideNavigationScreen |
||||
import com.whitefish.ring.ui.home.HomeScreen |
import com.whitefish.ring.ui.home.HomeScreen |
||||
import org.jetbrains.compose.ui.tooling.preview.Preview |
import org.jetbrains.compose.ui.tooling.preview.Preview |
||||
|
import org.koin.core.module.dsl.singleOf |
||||
|
import org.koin.dsl.module |
||||
|
|
||||
@Composable |
@Composable |
||||
@Preview |
@Preview |
||||
fun App() { |
fun App() { |
||||
MaterialTheme { |
MaterialTheme { |
||||
// HomeScreen( |
|
||||
// modifier = Modifier |
var guideComplete by remember { mutableStateOf(false) } |
||||
// ) |
|
||||
// DeviceScreen{ |
if (guideComplete){ |
||||
// |
HomeScreen() |
||||
// } |
}else{ |
||||
GuideNavigationScreen() |
GuideNavigationScreen { |
||||
|
guideComplete = true |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
@ -0,0 +1,3 @@ |
|||||
|
package com.whitefish.ring.bean.ui |
||||
|
|
||||
|
data class HeartRate (val time: Long,val value: Int) |
@ -0,0 +1,9 @@ |
|||||
|
package com.whitefish.ring.bean.ui |
||||
|
|
||||
|
import com.whitefish.app.ui.chart.sleep.SleepSegment |
||||
|
import kotlinx.datetime.Clock |
||||
|
|
||||
|
data class SleepState ( |
||||
|
val totalSeconds: Int, //秒 |
||||
|
val segments: List<SleepSegment> |
||||
|
) |
@ -0,0 +1,20 @@ |
|||||
|
package com.whitefish.ring.data |
||||
|
|
||||
|
import androidx.datastore.core.DataStore |
||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory |
||||
|
import androidx.datastore.preferences.core.Preferences |
||||
|
import androidx.datastore.preferences.core.stringPreferencesKey |
||||
|
import okio.Path.Companion.toPath |
||||
|
|
||||
|
|
||||
|
expect fun obtainDataStore(): DataStore<Preferences> |
||||
|
internal const val dataStoreFileName = "dice.preferences_pb" |
||||
|
|
||||
|
|
||||
|
val address = stringPreferencesKey("mac") |
||||
|
|
||||
|
|
||||
|
fun createDataStore(producePath: () -> String): DataStore<Preferences> = |
||||
|
PreferenceDataStoreFactory.createWithPath( |
||||
|
produceFile = { producePath().toPath() } |
||||
|
) |
@ -0,0 +1,11 @@ |
|||||
|
package com.whitefish.ring.data |
||||
|
|
||||
|
import com.whitefish.ring.bean.ui.HeartRate |
||||
|
import com.whitefish.ring.bean.ui.SleepState |
||||
|
import kotlinx.coroutines.flow.first |
||||
|
import kotlinx.coroutines.flow.last |
||||
|
import kotlinx.coroutines.flow.map |
||||
|
|
||||
|
suspend fun mac() = obtainDataStore().data.map { it[address] }.first() |
||||
|
expect suspend fun getHeartRate(start: Long,end: Long): List<HeartRate> |
||||
|
expect suspend fun getSleep(start: Long, end: Long): SleepState |
@ -0,0 +1,14 @@ |
|||||
|
package com.whitefish.ring.data |
||||
|
|
||||
|
object MockDataProvider { |
||||
|
|
||||
|
fun heartRate(start: Long, end: Long): List<com.whitefish.ring.bean.ui.HeartRate> { |
||||
|
return listOf( |
||||
|
com.whitefish.ring.bean.ui.HeartRate(value = 60, time = start), |
||||
|
com.whitefish.ring.bean.ui.HeartRate(value = 65, time = start + 1000), |
||||
|
com.whitefish.ring.bean.ui.HeartRate(value = 70, time = start + 2000), |
||||
|
com.whitefish.ring.bean.ui.HeartRate(value = 75, time = end - 1000), |
||||
|
com.whitefish.ring.bean.ui.HeartRate(value = 80, time = end) |
||||
|
) |
||||
|
} |
||||
|
} |
@ -0,0 +1,41 @@ |
|||||
|
package com.whitefish.ring.ui.guide |
||||
|
|
||||
|
import androidx.datastore.preferences.core.edit |
||||
|
import androidx.lifecycle.ViewModel |
||||
|
import androidx.lifecycle.viewModelScope |
||||
|
import com.whitefish.ring.data.address |
||||
|
import com.whitefish.ring.data.obtainDataStore |
||||
|
import io.github.aakira.napier.Napier |
||||
|
import kotlinx.coroutines.flow.MutableStateFlow |
||||
|
import kotlinx.coroutines.flow.asStateFlow |
||||
|
import kotlinx.coroutines.flow.collectLatest |
||||
|
import kotlinx.coroutines.flow.map |
||||
|
import kotlinx.coroutines.launch |
||||
|
|
||||
|
class NavigationViewModel: ViewModel() { |
||||
|
private val _bound = MutableStateFlow(false) |
||||
|
val bound = _bound.asStateFlow() |
||||
|
|
||||
|
init { |
||||
|
checkAlreadyConnected() |
||||
|
|
||||
|
} |
||||
|
|
||||
|
fun checkAlreadyConnected() { |
||||
|
viewModelScope.launch { |
||||
|
obtainDataStore().data.map { it[address] }.collectLatest { |
||||
|
_bound.value = !it.isNullOrEmpty() |
||||
|
Napier.i { "connect state:${!it.isNullOrEmpty()}" } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun clearCache(){ |
||||
|
viewModelScope.launch { |
||||
|
obtainDataStore().edit { |
||||
|
it[address] = "" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
} |
@ -0,0 +1,26 @@ |
|||||
|
package com.whitefish.ring.utils |
||||
|
|
||||
|
import kotlinx.datetime.Clock |
||||
|
import kotlinx.datetime.DateTimeUnit |
||||
|
import kotlinx.datetime.LocalDate |
||||
|
import kotlinx.datetime.LocalTime |
||||
|
import kotlinx.datetime.TimeZone |
||||
|
import kotlinx.datetime.UtcOffset |
||||
|
import kotlinx.datetime.atStartOfDayIn |
||||
|
import kotlinx.datetime.atTime |
||||
|
import kotlinx.datetime.minus |
||||
|
import kotlinx.datetime.toInstant |
||||
|
import kotlinx.datetime.toLocalDateTime |
||||
|
|
||||
|
|
||||
|
fun nowMilliseconds(): Long = Clock.System.now().toEpochMilliseconds() |
||||
|
|
||||
|
fun today() = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date |
||||
|
|
||||
|
fun getTodayStartMillis(): Long { |
||||
|
return today().atTime(0,0).toInstant(UtcOffset.ZERO).toEpochMilliseconds() |
||||
|
} |
||||
|
|
||||
|
fun getTodayEndMillis(): Long { |
||||
|
return today().atTime(23, 59, 59, 999999999).toInstant(UtcOffset.ZERO).toEpochMilliseconds() |
||||
|
} |
@ -0,0 +1,33 @@ |
|||||
|
package com.whitefish.ring.data |
||||
|
|
||||
|
import androidx.datastore.core.DataStore |
||||
|
import androidx.datastore.preferences.core.Preferences |
||||
|
import co.touchlab.stately.concurrency.Synchronizable |
||||
|
import com.whitefish.ring.data.createDataStore |
||||
|
import kotlinx.atomicfu.locks.SynchronizedObject |
||||
|
import kotlinx.atomicfu.locks.synchronized |
||||
|
import kotlinx.cinterop.ExperimentalForeignApi |
||||
|
import org.koin.mp.Lockable |
||||
|
import platform.Foundation.NSDocumentDirectory |
||||
|
import platform.Foundation.NSFileManager |
||||
|
import platform.Foundation.NSURL |
||||
|
import platform.Foundation.NSUserDomainMask |
||||
|
|
||||
|
|
||||
|
private var dataStoreInstance: DataStore<Preferences>? = null |
||||
|
private val lock = SynchronizedObject() |
||||
|
@OptIn(ExperimentalForeignApi::class) |
||||
|
actual fun obtainDataStore(): DataStore<Preferences> = synchronized(lock) { |
||||
|
dataStoreInstance ?: createDataStore( |
||||
|
producePath = { |
||||
|
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( |
||||
|
directory = NSDocumentDirectory, |
||||
|
inDomain = NSUserDomainMask, |
||||
|
appropriateForURL = null, |
||||
|
create = false, |
||||
|
error = null, |
||||
|
) |
||||
|
requireNotNull(documentDirectory).path + "/$dataStoreFileName" |
||||
|
} |
||||
|
).also { dataStoreInstance = it } |
||||
|
} |
@ -0,0 +1,123 @@ |
|||||
|
package com.whitefish.ring.data |
||||
|
|
||||
|
import com.whitefish.app.ui.chart.sleep.SleepSegment |
||||
|
import com.whitefish.ring.bean.ui.HeartRate |
||||
|
import com.whitefish.ring.bean.ui.SleepState |
||||
|
import com.whitefish.ring.objc.DBHeartRate |
||||
|
import com.whitefish.ring.objc.DBSleepData |
||||
|
import com.whitefish.ring.objc.StagingListObj |
||||
|
import com.whitefish.ring.objc.StagingSubObj |
||||
|
import com.whitefish.ring.utils.getCurrentTimestamp |
||||
|
import com.whitefish.ring.utils.timestampToDate |
||||
|
import kotlinx.cinterop.ExperimentalForeignApi |
||||
|
import kotlinx.coroutines.Dispatchers |
||||
|
import kotlinx.coroutines.IO |
||||
|
import kotlinx.coroutines.withContext |
||||
|
import platform.Foundation.timeIntervalSince1970 |
||||
|
import kotlin.coroutines.resume |
||||
|
import kotlin.coroutines.suspendCoroutine |
||||
|
|
||||
|
@OptIn(ExperimentalForeignApi::class) |
||||
|
actual suspend fun getHeartRate(start: Long, end: Long): List<HeartRate> = |
||||
|
withContext(Dispatchers.IO) { |
||||
|
val macAddress = mac() |
||||
|
return@withContext suspendCoroutine { coroutine -> |
||||
|
macAddress?.let { |
||||
|
DBHeartRate.queryBy( |
||||
|
it, |
||||
|
start.timestampToDate(), |
||||
|
end.timestampToDate(), |
||||
|
false |
||||
|
) { result, max, min, avg -> |
||||
|
val heartList = result?.map { it as DBHeartRate } |
||||
|
?.map { HeartRate(it.time.getCurrentTimestamp(), it.value.intValue) } |
||||
|
if (heartList.isNullOrEmpty()) { |
||||
|
coroutine.resume(emptyList()) |
||||
|
} else { |
||||
|
coroutine.resume(heartList) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@OptIn(ExperimentalForeignApi::class) |
||||
|
actual suspend fun getSleep( |
||||
|
start: Long, |
||||
|
end: Long |
||||
|
): SleepState = withContext(Dispatchers.IO) { |
||||
|
val macAddress = mac() |
||||
|
return@withContext suspendCoroutine { coroutine -> |
||||
|
macAddress?.let { |
||||
|
val segments = mutableListOf<SleepSegment>() |
||||
|
|
||||
|
DBSleepData.queryDbSleepBy( |
||||
|
it, start.timestampToDate().timeIntervalSince1970, |
||||
|
end.timestampToDate().timeIntervalSince1970 |
||||
|
) { result -> |
||||
|
val sleepDataList = result?.map { it as DBSleepData } |
||||
|
var totalSeconds = 0 |
||||
|
|
||||
|
sleepDataList?.let { dataList -> |
||||
|
dataList.forEach { sleepData -> |
||||
|
if (!sleepData.isNap) { |
||||
|
totalSeconds += sleepData.duration.intValue |
||||
|
|
||||
|
// 处理睡眠分期数据 |
||||
|
sleepData.stagingData?.ousideStagingList?.let { stagingList -> |
||||
|
for (i in 0 until stagingList.size) { |
||||
|
val stagingObj = stagingList[i] as StagingSubObj |
||||
|
|
||||
|
// 计算该阶段的持续时间 |
||||
|
val duration = if (i == 0) { |
||||
|
// 第一个阶段:从开始到结束 |
||||
|
stagingObj.list.let { list -> |
||||
|
val firstTime = (list.first() as? StagingListObj)?.time?.doubleValue ?: 0.0 |
||||
|
val lastTime = (list.last() as? StagingListObj)?.time?.doubleValue ?: 0.0 |
||||
|
lastTime - firstTime |
||||
|
} |
||||
|
} else { |
||||
|
// 后续阶段:从前一个阶段结束到当前阶段结束 |
||||
|
val prevStagingObj = stagingList[i - 1] as StagingSubObj |
||||
|
val currentEndTime = (stagingObj.list.first() as? StagingListObj)?.time?.doubleValue ?: 0.0 |
||||
|
val prevEndTime = (prevStagingObj.list.last() as? StagingListObj)?.time?.doubleValue ?: 0.0 |
||||
|
currentEndTime - prevEndTime |
||||
|
} |
||||
|
|
||||
|
// 转换睡眠状态类型到用户定义的状态 |
||||
|
val mappedState = mapSleepStageToState(stagingObj.type.value.toInt()) |
||||
|
|
||||
|
// 只添加有效的睡眠状态(跳过 NONE 状态) |
||||
|
if (mappedState >= 0) { |
||||
|
val durationMinutes = (duration / 60.0).toFloat() |
||||
|
if (durationMinutes > 0) { |
||||
|
segments.add(SleepSegment(mappedState, durationMinutes)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
coroutine.resume(SleepState(totalSeconds =totalSeconds, segments = segments )) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 将原始睡眠状态映射到用户定义的状态 |
||||
|
* @param originalType 原始睡眠状态类型 |
||||
|
* @return 映射后的状态 (0=清醒, 1=浅睡, 2=深睡, 3=REM, -1=忽略) |
||||
|
*/ |
||||
|
private fun mapSleepStageToState(originalType: Int): Int { |
||||
|
return when (originalType) { |
||||
|
0 -> -1 // NONE - 忽略 |
||||
|
1 -> 0 // WAKE - 清醒 |
||||
|
2, 3 -> 1 // NREM1, NREM2 - 浅睡 |
||||
|
4 -> 2 // NREM3 - 深睡 |
||||
|
5 -> 3 // REM - REM睡眠 |
||||
|
6 -> -1 // NAP - 忽略(因为已经过滤了 isNap) |
||||
|
else -> -1 |
||||
|
} |
||||
|
} |
@ -0,0 +1,15 @@ |
|||||
|
package com.whitefish.ring.utils |
||||
|
|
||||
|
import platform.Foundation.NSDate |
||||
|
import platform.Foundation.NSTimeInterval |
||||
|
import platform.Foundation.dateWithTimeIntervalSince1970 |
||||
|
import platform.Foundation.timeIntervalSince1970 |
||||
|
|
||||
|
fun Long.timestampToDate(): NSDate { |
||||
|
val timeInterval: NSTimeInterval = this / 1000.0 |
||||
|
return NSDate.dateWithTimeIntervalSince1970(timeInterval) |
||||
|
} |
||||
|
|
||||
|
fun NSDate.getCurrentTimestamp(): Long { |
||||
|
return (this.timeIntervalSince1970 * 1000).toLong() |
||||
|
} |
Loading…
Reference in new issue