├── app
├── logo
│ ├── app-logo.png
│ └── app-logo-ios.png
├── src
│ ├── commonMain
│ │ ├── kotlin
│ │ │ └── io
│ │ │ │ └── sellmair
│ │ │ │ └── pacemaker
│ │ │ │ ├── ui
│ │ │ │ ├── Intent.kt
│ │ │ │ ├── Page.kt
│ │ │ │ ├── logging.kt
│ │ │ │ ├── BackHandlerIfAny.kt
│ │ │ │ ├── mainPage
│ │ │ │ │ ├── MainPage.kt
│ │ │ │ │ ├── GroupHeartRateScale.kt
│ │ │ │ │ ├── UtteranceControlButton.kt
│ │ │ │ │ └── MyStatusHeader.kt
│ │ │ │ ├── colorUtils.kt
│ │ │ │ ├── widget
│ │ │ │ │ ├── OnHeartRateScaleSpring.kt
│ │ │ │ │ ├── GradientBackdrop.kt
│ │ │ │ │ ├── Headline.kt
│ │ │ │ │ ├── ColorHueSlider.kt
│ │ │ │ │ ├── coroutineUtils.kt
│ │ │ │ │ ├── OnHeartRateScalePosition.kt
│ │ │ │ │ ├── ExperimentalFeatureToggle.kt
│ │ │ │ │ ├── MemberHeartRateLimit.kt
│ │ │ │ │ ├── UserHead.kt
│ │ │ │ │ ├── MemberHeartRateIndicator.kt
│ │ │ │ │ └── HeartRateScale.kt
│ │ │ │ ├── settingsPage
│ │ │ │ │ ├── SettingsPage.kt
│ │ │ │ │ └── SettingsPageHeader.kt
│ │ │ │ ├── ApplicationWindow.kt
│ │ │ │ ├── launchHeartRateUtteranceActor.kt
│ │ │ │ ├── PacemakerTheme.kt
│ │ │ │ └── timelinePage
│ │ │ │ │ └── TimelinePage.kt
│ │ │ │ └── launchFrontendServices.kt
│ │ └── composeResources
│ │ │ ├── values
│ │ │ └── strings.xml
│ │ │ ├── values-de
│ │ │ └── strings.xml
│ │ │ └── values-it
│ │ │ └── strings.xml
│ ├── androidMain
│ │ ├── play_store_512.png
│ │ ├── res
│ │ │ ├── values
│ │ │ │ └── styles.xml
│ │ │ ├── xml
│ │ │ │ └── locales_config.xml
│ │ │ └── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher_round.xml
│ │ │ │ └── ic_launcher.xml
│ │ └── kotlin
│ │ │ ├── AndroidBackHandler.kt
│ │ │ └── Previews.kt
│ ├── jvmMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ └── ui
│ │ │ ├── BackHandlerIfAny.jvm.kt
│ │ │ └── main.kt
│ ├── appleMain
│ │ └── kotlin
│ │ │ └── BackHandlerIfAny.apple.kt
│ └── iosMain
│ │ └── kotlin
│ │ ├── backend.kt
│ │ └── IosPacemakerViewController.kt
└── build.gradle.kts
├── app-core
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ ├── Settings.kt
│ │ │ ├── SqlDdriver.kt
│ │ │ ├── logging.kt
│ │ │ ├── UtteranceEvent.kt
│ │ │ ├── UserState.kt
│ │ │ ├── SessionRecord.kt
│ │ │ ├── meId.kt
│ │ │ ├── withHeartRateSensor.kt
│ │ │ ├── UtteranceState.kt
│ │ │ ├── CriticalGroupState.kt
│ │ │ ├── launchUtteranceSettingsActor.kt
│ │ │ ├── launchHeartRateSensorMeasurement.kt
│ │ │ ├── launchPacemakerBroadcastReceiver.kt
│ │ │ ├── launchUpdateMeActor.kt
│ │ │ ├── launchHeartRateSensorLinkingActor.kt
│ │ │ ├── SqlActiveSessionService.kt
│ │ │ ├── SessionsState.kt
│ │ │ ├── colorUtils.kt
│ │ │ ├── UserService.kt
│ │ │ ├── SessionService.kt
│ │ │ ├── MeColorState.kt
│ │ │ ├── SafePacemakerDatabase.kt
│ │ │ ├── HeartRateSensorsState.kt
│ │ │ ├── launchHeartRateSensorAutoConnector.kt
│ │ │ ├── BluetoothState.kt
│ │ │ ├── ApplicationBackend.kt
│ │ │ ├── launchAdhocUserActor.kt
│ │ │ ├── ApplicationFeature.kt
│ │ │ ├── launchHeartRateUtteranceProducer.kt
│ │ │ ├── MeState.kt
│ │ │ ├── sessionActor.kt
│ │ │ ├── SqlSessionService.kt
│ │ │ └── launchPacemakerBroadcastSender.kt
│ ├── androidMain
│ │ ├── play_store_512.png
│ │ ├── res
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_background.png
│ │ │ │ ├── ic_launcher_foreground.png
│ │ │ │ └── ic_launcher_monochrome.png
│ │ │ └── mipmap-anydpi-v26
│ │ │ │ └── ic_launcher.xml
│ │ ├── kotlin
│ │ │ └── io
│ │ │ │ └── sellmair
│ │ │ │ └── pacemaker
│ │ │ │ ├── ApplicationBackend.android.kt
│ │ │ │ ├── BluetoothState.android.kt
│ │ │ │ ├── AndroidContextProvider.kt
│ │ │ │ ├── launchVibrationActor.android.kt
│ │ │ │ └── launchTextToSpeech.kt
│ │ └── AndroidManifest.xml
│ ├── jvmMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ ├── BluetoothState.jvm.kt
│ │ │ └── ApplicationBackend.jvm.kt
│ ├── iosMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ ├── ApplicationBackend.apple.kt
│ │ │ ├── BluetoothState.ios.kt
│ │ │ ├── launchVibrationActor.apple.kt
│ │ │ ├── launchSpeechSynthesizer.kt
│ │ │ └── IosApplicationBackend.kt
│ ├── androidUnitTest
│ │ └── kotlin
│ │ │ ├── utils
│ │ │ └── InMemoryDatabase.kt
│ │ │ └── GroupStateTest.kt
│ └── sql
│ │ └── io
│ │ └── sellmair
│ │ └── pacemaker
│ │ ├── Session.sq
│ │ └── User.sq
└── build.gradle.kts
├── .img
├── screenshot-ios-1.png
├── screenshot-ios-2.png
└── screenshot-android-1.png
├── iosApp
├── Podfile
├── Pacemaker
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ ├── app-logo-ios.png
│ │ │ └── Contents.json
│ │ └── AccentColor.colorset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── PacemakerApp.swift
│ ├── Info.plist
│ └── ContentView.swift
├── Pacemaker.xcodeproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ ├── xcuserdata
│ │ │ └── sebastiansellmair.xcuserdatad
│ │ │ │ └── UserInterfaceState.xcuserstate
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcuserdata
│ │ └── sebastiansellmair.xcuserdatad
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
└── Pacemaker.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── .fleet
└── settings.json
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── models
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── io
│ │ └── sellmair
│ │ └── pacemaker
│ │ └── model
│ │ ├── SessionId.kt
│ │ ├── HeartRateSensorId.kt
│ │ ├── HeartRateSensorInfo.kt
│ │ ├── Session.kt
│ │ ├── UserId.kt
│ │ ├── Hue.kt
│ │ ├── User.kt
│ │ └── HeartRate.kt
│ └── commonTest
│ └── kotlin
│ └── HeartRateEncodingTest.kt
├── bluetooth-core
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ └── ble
│ │ │ ├── BleUUID.kt
│ │ │ ├── logging.kt
│ │ │ ├── BleDeviceId.kt
│ │ │ ├── BleCharacteristicDescriptor.kt
│ │ │ ├── BleServiceDescriptor.kt
│ │ │ ├── BleCentralControkller.kt
│ │ │ ├── BleOperation.kt
│ │ │ ├── BleConnectableController.kt
│ │ │ ├── BlePeripheralController.kt
│ │ │ ├── BleStatusCode.kt
│ │ │ ├── impl
│ │ │ ├── BleConnectionImpl.kt
│ │ │ └── BleCentralServiceImpl.kt
│ │ │ └── Ble.kt
│ ├── jvmMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ └── ble
│ │ │ └── BleUUID.jvm.kt
│ ├── androidMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ └── ble
│ │ │ ├── AndroidBleUUID.kt
│ │ │ ├── AndroidConnectableHardware.kt
│ │ │ ├── AndroidCentralHardware.kt
│ │ │ ├── AndroidBluetoothExtensions.kt
│ │ │ └── AndroidBleImpl.kt
│ └── appleMain
│ │ └── kotlin
│ │ └── io
│ │ └── sellmair
│ │ └── pacemaker
│ │ └── ble
│ │ ├── AppleBleUUID.kt
│ │ ├── AppleConnectableHardware.kt
│ │ ├── AppleBluetoothExtensions.kt
│ │ ├── AppleCentralHardware.kt
│ │ ├── AppleBle.kt
│ │ ├── AppleCBMutableServiceFactory.kt
│ │ ├── ApplePeripheralHardware.kt
│ │ ├── AppleCentralController.kt
│ │ └── ApplePeripheralManagerDelegate.kt
└── build.gradle.kts
├── bluetooth
├── src
│ └── commonMain
│ │ └── kotlin
│ │ └── io
│ │ └── sellmair
│ │ └── pacemaker
│ │ └── bluetooth
│ │ ├── PacemakerBroadcastPackageEvent.kt
│ │ ├── Extensions.kt
│ │ ├── HeartRateSensorMeasurement.kt
│ │ ├── PacemakerBluetoothWritable.kt
│ │ ├── PacemakerBroadcastPackage.kt
│ │ ├── HeartRateMeasurementEvent.kt
│ │ ├── PacemakerServiceConstants.kt
│ │ ├── HeartRateSensorServiceDescriptors.kt
│ │ ├── PacemakerBluetoothConnection.kt
│ │ ├── PacemakerBluetoothWritableImpl.kt
│ │ ├── PacemakerBluetoothService.kt
│ │ └── PacemakerServiceDescriptors.kt
└── build.gradle.kts
├── utils
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── io
│ │ │ └── sellmair
│ │ │ └── pacemaker
│ │ │ └── utils
│ │ │ ├── coroutineUtils.kt
│ │ │ ├── ioUtils.kt
│ │ │ ├── conversionUtils.kt
│ │ │ ├── Configuration.kt
│ │ │ └── Logging.kt
│ ├── jvmAndAndroidMain
│ │ └── kotlin
│ │ │ └── ioUtils.jvmAndAndroid.kt
│ ├── nonAndroidMain
│ │ └── kotlin
│ │ │ └── Logging.nonAndroid.kt
│ ├── androidMain
│ │ └── kotlin
│ │ │ └── Logging.android.kt
│ ├── nativeMain
│ │ └── kotlin
│ │ │ └── ioUtils.native.kt
│ └── commonTest
│ │ └── kotlin
│ │ └── io
│ │ └── sellmair
│ │ └── pacemaker
│ │ └── utils
│ │ └── ConfigurationTest.kt
└── build.gradle.kts
├── gradle.properties
├── .gitignore
├── DataProtection.md
├── spoof-tool
├── build.gradle.kts
└── src
│ └── macosMain
│ └── kotlin
│ └── io.sellmair.broadheart.spoof
│ └── Main.kt
├── .github
└── workflows
│ └── build.yaml
├── settings.gradle.kts
├── .run
└── 🔥app [jvm].run.xml
├── dependencies.toml
└── gradlew.bat
/app/logo/app-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app/logo/app-logo.png
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/Settings.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/Intent.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 |
--------------------------------------------------------------------------------
/.img/screenshot-ios-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/.img/screenshot-ios-1.png
--------------------------------------------------------------------------------
/.img/screenshot-ios-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/.img/screenshot-ios-2.png
--------------------------------------------------------------------------------
/app/logo/app-logo-ios.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app/logo/app-logo-ios.png
--------------------------------------------------------------------------------
/iosApp/Podfile:
--------------------------------------------------------------------------------
1 | target 'Pacemaker' do
2 | platform :ios, '14.1'
3 | pod 'PM', :path => '../app'
4 | end
--------------------------------------------------------------------------------
/.img/screenshot-android-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/.img/screenshot-android-1.png
--------------------------------------------------------------------------------
/.fleet/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "backend.maxHeapSizeMb": 4096,
3 | "run.destination.stop.already.running": "Always"
4 | }
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/androidMain/play_store_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app/src/androidMain/play_store_512.png
--------------------------------------------------------------------------------
/iosApp/Pacemaker/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/play_store_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/play_store_512.png
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SqlDdriver.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 |
5 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/models/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("pacemaker-library")
3 | }
4 |
5 | pacemaker {
6 | jvm()
7 | ios()
8 | macos()
9 | }
10 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/Page.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | enum class Page {
4 | MainPage, TimelinePage, SettingsPage
5 | }
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleUUID.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | expect fun BleUUID(value: String): BleUUID
4 | expect class BleUUID
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/iosApp/Pacemaker/Assets.xcassets/AppIcon.appiconset/app-logo-ios.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/iosApp/Pacemaker/Assets.xcassets/AppIcon.appiconset/app-logo-ios.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/logging.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.utils.LogTag
4 |
5 | val LogTag.Companion.appCore get() = LogTag("app-core")
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/SessionId.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | import kotlin.jvm.JvmInline
4 |
5 | @JvmInline
6 | value class SessionId(val value: Long)
--------------------------------------------------------------------------------
/bluetooth-core/src/jvmMain/kotlin/io/sellmair/pacemaker/ble/BleUUID.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | actual fun BleUUID(value: String): BleUUID = value
4 |
5 | actual typealias BleUUID = String
6 |
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/HeartRateSensorId.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | import kotlin.jvm.JvmInline
4 |
5 | @JvmInline
6 | value class HeartRateSensorId(val value: String)
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/HeartRateSensorInfo.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | data class HeartRateSensorInfo(
4 | val id: HeartRateSensorId,
5 | val rssi: Int? = null,
6 | )
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/logging.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import io.sellmair.pacemaker.utils.LogTag
4 |
5 | private val uiTag = LogTag("ui")
6 |
7 | val LogTag.Companion.ui get() = uiTag
--------------------------------------------------------------------------------
/app/src/androidMain/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/jvmMain/kotlin/io/sellmair/pacemaker/ui/BackHandlerIfAny.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | @androidx.compose.runtime.Composable
4 | internal actual fun BackHandlerIfAny(enabled: Boolean, onBack: () -> Unit) {
5 | }
6 |
--------------------------------------------------------------------------------
/app/src/appleMain/kotlin/BackHandlerIfAny.apple.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | internal actual fun BackHandlerIfAny(enabled: Boolean, onBack: () -> Unit) = Unit
7 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/logging.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import io.sellmair.pacemaker.utils.LogTag
4 |
5 | private val bleTag = LogTag("ble")
6 |
7 | val LogTag.Companion.ble get() = bleTag
8 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/BackHandlerIfAny.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | internal expect fun BackHandlerIfAny(enabled: Boolean = true, onBack: () -> Unit)
7 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleDeviceId.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import kotlin.jvm.JvmInline
4 |
5 | @JvmInline
6 | value class BleDeviceId(val value: String) {
7 | override fun toString(): String = value
8 | }
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBroadcastPackageEvent.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.evas.Event
4 |
5 | data class PacemakerBroadcastPackageEvent(val pkg: PacemakerBroadcastPackage): Event
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/Session.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | import kotlin.time.Instant
4 |
5 | data class Session(
6 | val id: SessionId,
7 | val startTime: Instant,
8 | val endTime: Instant?
9 | )
10 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/MainPage.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.mainPage
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | internal fun MainPage() {
7 | GroupHeartRateOverview()
8 | MyStatusHeader()
9 | }
--------------------------------------------------------------------------------
/app/src/androidMain/res/xml/locales_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker.xcodeproj/project.xcworkspace/xcuserdata/sebastiansellmair.xcuserdatad/UserInterfaceState.xcuserstate:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/iosApp/Pacemaker.xcodeproj/project.xcworkspace/xcuserdata/sebastiansellmair.xcuserdatad/UserInterfaceState.xcuserstate
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed May 29 16:30:04 CEST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/Extensions.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.pacemaker.ble.BleDeviceId
4 | import io.sellmair.pacemaker.model.HeartRateSensorId
5 |
6 | fun BleDeviceId.toHeartRateSensorId() = HeartRateSensorId(value)
--------------------------------------------------------------------------------
/app/src/androidMain/kotlin/AndroidBackHandler.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | actual fun BackHandlerIfAny(enabled: Boolean, onBack: () -> Unit) {
7 | androidx.activity.compose.BackHandler(enabled = enabled, onBack = onBack)
8 | }
--------------------------------------------------------------------------------
/bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidBleUUID.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import java.util.*
4 |
5 | actual fun BleUUID(value: String): BleUUID = UUID.fromString(value)
6 |
7 | actual typealias BleUUID = UUID
8 |
9 | fun BleUUID(uuid: UUID) : BleUUID = uuid
--------------------------------------------------------------------------------
/app-core/src/jvmMain/kotlin/io/sellmair/pacemaker/BluetoothState.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | internal actual suspend fun isBluetoothEnabled(): Boolean {
4 | return true
5 | }
6 |
7 | internal actual suspend fun isBluetoothPermissionGranted(): Boolean {
8 | return true
9 | }
10 |
--------------------------------------------------------------------------------
/utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/coroutineUtils.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import kotlin.coroutines.suspendCoroutine
4 |
5 | /**
6 | * This function will return 'never'
7 | */
8 | suspend fun never(): Nothing {
9 | suspendCoroutine {}
10 | }
11 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UtteranceEvent.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.Event
4 |
5 | data class UtteranceEvent(
6 | val type: Type,
7 | val message: String
8 | ): Event {
9 | enum class Type {
10 | Info, Warning
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/launchFrontendServices.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.ui.launchHeartRateUtteranceActor
4 | import kotlinx.coroutines.CoroutineScope
5 |
6 | fun CoroutineScope.launchFrontendServices() {
7 | launchHeartRateUtteranceActor()
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/colorUtils.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.ui.graphics.Color
4 | import io.sellmair.pacemaker.HSLColor
5 |
6 | fun HSLColor.toColor(alpha: Float = 1f): Color {
7 | return Color.hsl(hue, saturation, lightness, alpha = alpha)
8 | }
9 |
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleBleUUID.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import platform.CoreBluetooth.CBUUID
4 | import platform.CoreBluetooth.CBUUID.Companion.UUIDWithString
5 |
6 | actual fun BleUUID(value: String): BleUUID = UUIDWithString(value)
7 | actual typealias BleUUID = CBUUID
--------------------------------------------------------------------------------
/iosApp/Pacemaker.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "app-logo-ios.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleConnectableHardware.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import platform.CoreBluetooth.CBPeripheral
4 |
5 | internal class AppleConnectableHardware(
6 | val peripheral: CBPeripheral,
7 | val delegate: ApplePeripheralDelegate,
8 | val serviceDescriptor: BleServiceDescriptor
9 | )
--------------------------------------------------------------------------------
/utils/src/jvmAndAndroidMain/kotlin/ioUtils.jvmAndAndroid.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import okio.FileSystem
4 |
5 | /* https://youtrack.jetbrains.com/issue/KTIJ-25140/Okio-False-positive-MISSINGDEPENDENCYSUPERCLASS-on-FileSystem.SYSTEM */
6 | @Suppress("UNUSED")
7 | actual fun defaultFileSystem(): FileSystem {
8 | return FileSystem.SYSTEM
9 | }
--------------------------------------------------------------------------------
/iosApp/Pacemaker/PacemakerApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PacemakerApp.swift
3 | // Pacemaker
4 | //
5 | // Created by Sebastian Sellmair on 13.03.23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct PacemakerApp: App {
12 | var body: some Scene {
13 | WindowGroup {
14 | ContentView().edgesIgnoringSafeArea([.bottom, .top]) }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/OnHeartRateScaleSpring.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.animation.core.Spring
4 | import androidx.compose.animation.core.spring
5 |
6 | fun onHeartRateScaleSpring() = spring(
7 | dampingRatio = Spring.DampingRatioMediumBouncy,
8 | stiffness = Spring.StiffnessLow
9 | )
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | android.useAndroidX=true
2 | kotlin.code.style=official
3 | org.jetbrains.compose.experimental.uikit.enabled=true
4 | org.jetbrains.compose.experimental.macos.enabled=true
5 | org.gradle.jvmargs=-Xmx12g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
6 | org.gradle.configuration-cache=true
7 | org.gradle.caching=true
8 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UserState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.User
5 |
6 | data class UserState(
7 | val user: User,
8 | val isMe: Boolean,
9 | val heartRate: HeartRate,
10 | val heartRateLimit: HeartRate?,
11 | val color: HSLColor
12 | )
--------------------------------------------------------------------------------
/bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidConnectableHardware.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import android.bluetooth.BluetoothDevice
4 | import android.content.Context
5 |
6 | internal class AndroidConnectableHardware(
7 | val context: Context,
8 | val device: BluetoothDevice,
9 | val callback: AndroidGattCallback
10 | )
11 |
12 |
--------------------------------------------------------------------------------
/app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleBluetoothExtensions.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import platform.CoreBluetooth.CBCentral
4 | import platform.CoreBluetooth.CBPeripheral
5 |
6 | val CBPeripheral.deviceId: BleDeviceId get() = BleDeviceId(this.identifier.UUIDString)
7 | val CBCentral.deviceId: BleDeviceId get() = BleDeviceId(this.identifier.UUIDString)
8 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.android.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.launch
5 |
6 | actual fun ApplicationBackend.launchPlatform(scope: CoroutineScope) {
7 | this as AndroidApplicationBackend
8 | scope.launchVibrationWarningActor(this)
9 | scope.launchTextToSpeech(this)
10 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | **/build/**
2 | .gradle
3 | .kotlin
4 | local.properties
5 | .idea
6 | spoof-tool/spoof
7 |
8 | # Ios specific:
9 | .DS_Store
10 | iosApp/Podfile.lock
11 | iosApp/Pods/*
12 | iosApp/Pacemaker.xcworkspace/*
13 | iosApp/Pacemaker.xcodeproj/*
14 | !iosApp/Pacemaker.xcodeproj/project.pbxproj
15 | app/PM.podspec
16 |
17 | # Addition
18 | /captures
19 | .externalNativeBuild
20 | .cxx
21 | /app/release/
22 |
--------------------------------------------------------------------------------
/app-core/src/iosMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.apple.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import kotlinx.cinterop.ExperimentalForeignApi
4 | import kotlinx.coroutines.CoroutineScope
5 |
6 |
7 | @OptIn(ExperimentalForeignApi::class)
8 | actual fun ApplicationBackend.launchPlatform(scope: CoroutineScope) {
9 | scope.launchVibrationWarningActor()
10 | scope.launchSpeechSynthesizer()
11 | }
--------------------------------------------------------------------------------
/bluetooth-core/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("pacemaker-library")
3 | }
4 |
5 | pacemaker {
6 | android()
7 | ios()
8 | macos()
9 | jvm()
10 | }
11 |
12 | kotlin {
13 | sourceSets.commonMain.dependencies {
14 | api(project(":models"))
15 | }
16 |
17 | sourceSets.androidMain.dependencies {
18 | implementation("androidx.annotation:annotation:1.7.0")
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/utils/src/nonAndroidMain/kotlin/Logging.nonAndroid.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | actual object Log {
4 | actual operator fun invoke(tag: LogTag, level: LogLevel, message: String, throwable: Throwable?) {
5 | println(
6 | "[$tag | $level]: $message" + if (throwable != null) {
7 | "\n${throwable.stackTraceToString()}"
8 | } else ""
9 | )
10 | }
11 | }
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/HeartRateSensorMeasurement.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.HeartRateSensorInfo
5 | import kotlin.time.Instant
6 |
7 | data class HeartRateSensorMeasurement(
8 | val heartRate: HeartRate,
9 | val sensorInfo: HeartRateSensorInfo,
10 | val receivedTime: Instant
11 | )
12 |
--------------------------------------------------------------------------------
/utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/ioUtils.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import okio.FileSystem
4 | import okio.Path
5 | import okio.buffer
6 | import okio.use
7 |
8 | fun FileSystem.readUtf8OrNull(path: Path): String? {
9 | return try {
10 | source(path).buffer().use { it.readUtf8() }
11 | } catch (t: Throwable) {
12 | null
13 | }
14 | }
15 |
16 | expect fun defaultFileSystem(): FileSystem
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleCharacteristicDescriptor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | data class BleCharacteristicDescriptor(
4 | val name: String,
5 | val uuid: BleUUID,
6 | val isReadable: Boolean = true,
7 | val isWritable: Boolean = false,
8 | val isNotificationsEnabled: Boolean = false
9 | ) {
10 | override fun toString(): String {
11 | return "Characteristic($name)"
12 | }
13 | }
--------------------------------------------------------------------------------
/bluetooth/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("pacemaker-library")
3 | }
4 |
5 | pacemaker {
6 | android()
7 | ios()
8 | macos()
9 | jvm()
10 | }
11 |
12 | kotlin {
13 | sourceSets.commonMain.dependencies {
14 | api(project(":bluetooth-core"))
15 | implementation(Dependencies.okio)
16 | }
17 |
18 | sourceSets.androidMain.dependencies {
19 | implementation("androidx.annotation:annotation:1.7.0")
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SessionRecord.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.SessionId
5 | import io.sellmair.pacemaker.model.UserId
6 | import kotlin.time.Instant
7 |
8 | data class SessionRecord(
9 | val sessionId: SessionId,
10 | val userId: UserId,
11 | val time: Instant,
12 | val heartRate: HeartRate,
13 | val heartRateLimit: HeartRate?
14 | )
15 |
--------------------------------------------------------------------------------
/app/src/iosMain/kotlin/backend.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.SupervisorJob
6 |
7 | internal val backend by lazy {
8 | IosApplicationBackend().also { backend ->
9 | val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + backend.events + backend.states)
10 | scope.launchFrontendServices()
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app-core/src/androidUnitTest/kotlin/utils/InMemoryDatabase.kt:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
4 | import io.sellmair.pacemaker.SafePacemakerDatabase
5 | import io.sellmair.pacemaker.sql.PacemakerDatabase
6 |
7 | internal fun createInMemoryDatabase(): SafePacemakerDatabase = SafePacemakerDatabase {
8 | val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
9 | PacemakerDatabase.Schema.create(driver)
10 | PacemakerDatabase(driver)
11 | }
12 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/kotlin/io/sellmair/pacemaker/BluetoothState.android.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.ble.isBluetoothEnabled
4 | import io.sellmair.pacemaker.ble.isBluetoothPermissionGranted
5 |
6 | internal actual suspend fun isBluetoothEnabled(): Boolean {
7 | return androidContext().isBluetoothEnabled()
8 | }
9 |
10 | internal actual suspend fun isBluetoothPermissionGranted(): Boolean {
11 | return androidContext().isBluetoothPermissionGranted()
12 | }
13 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/meId.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import com.russhwolf.settings.Settings
4 | import com.russhwolf.settings.set
5 | import io.sellmair.pacemaker.model.UserId
6 | import kotlin.random.Random
7 |
8 | internal val Settings.meId: UserId
9 | get() {
10 | val key = "me.id"
11 | getLongOrNull(key)?.let { return UserId(it) }
12 | val newId = Random.nextLong()
13 | set(key, newId)
14 | return UserId(newId)
15 | }
16 |
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothWritable.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.Hue
5 | import io.sellmair.pacemaker.model.User
6 |
7 | interface PacemakerBluetoothWritable {
8 | suspend fun setUser(user: User)
9 | suspend fun setHeartRate(heartRate: HeartRate)
10 | suspend fun setHeartRateLimit(heartRate: HeartRate)
11 | suspend fun setColorHue(hue: Hue)
12 | }
13 |
--------------------------------------------------------------------------------
/models/src/commonTest/kotlin/HeartRateEncodingTest.kt:
--------------------------------------------------------------------------------
1 | import io.sellmair.pacemaker.model.HeartRate
2 | import io.sellmair.pacemaker.model.encodeToByteArray
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class HeartRateEncodingTest {
7 | @Test
8 | fun `test - 130`() {
9 | assertEquals(HeartRate(130), HeartRate(HeartRate(130).encodeToByteArray()))
10 | }
11 |
12 | @Test
13 | fun `test - 10`() {
14 | assertEquals(HeartRate(10f), HeartRate(HeartRate(10f).encodeToByteArray()))
15 | }
16 | }
--------------------------------------------------------------------------------
/bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidCentralHardware.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import android.bluetooth.BluetoothManager
4 | import android.content.Context
5 |
6 | internal fun AndroidCentralHardware(context: Context): AndroidCentralHardware {
7 | val manager = context.getSystemService(BluetoothManager::class.java)
8 | return AndroidCentralHardware(context, manager)
9 | }
10 |
11 | internal class AndroidCentralHardware(
12 | val context: Context,
13 | val manager: BluetoothManager
14 | )
15 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleServiceDescriptor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | data class BleServiceDescriptor(
4 | val name: String,
5 | val uuid: BleUUID,
6 | val characteristics: Set
7 | ) {
8 | private val characteristicsByUUID = characteristics.associateBy { it.uuid }
9 |
10 | fun findCharacteristic(uuid: BleUUID): BleCharacteristicDescriptor? = characteristicsByUUID[uuid]
11 |
12 | override fun toString(): String {
13 | return "Service($name)"
14 | }
15 | }
--------------------------------------------------------------------------------
/utils/src/androidMain/kotlin/Logging.android.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | actual object Log {
4 | actual operator fun invoke(tag: LogTag, level: LogLevel, message: String, throwable: Throwable?) {
5 | when (level) {
6 | LogLevel.Debug -> android.util.Log.d(tag.name, message, throwable)
7 | LogLevel.Info -> android.util.Log.i(tag.name, message, throwable)
8 | LogLevel.Warn -> android.util.Log.w(tag.name, message, throwable)
9 | LogLevel.Error -> android.util.Log.e(tag.name, message, throwable)
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/utils/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
2 |
3 | plugins {
4 | id("pacemaker-library")
5 | }
6 |
7 | pacemaker {
8 | jvm()
9 | macos()
10 | android()
11 | ios()
12 |
13 | sourceSets {
14 | useNonAndroid()
15 | useJvmAndAndroid()
16 | }
17 |
18 | features {
19 | useAtomicFu()
20 | }
21 | }
22 |
23 | kotlin {
24 | androidTarget {
25 | @Suppress("OPT_IN_USAGE")
26 | instrumentedTestVariant {
27 | sourceSetTree = KotlinSourceSetTree.test
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/iosApp/Pacemaker/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | LSMinimumSystemVersion
6 | 14.0
7 | CADisableMinimumFrameDurationOnPhone
8 |
9 | ITSAppUsesNonExemptEncryption
10 |
11 | UIBackgroundModes
12 |
13 | audio
14 | bluetooth-central
15 | bluetooth-peripheral
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/DataProtection.md:
--------------------------------------------------------------------------------
1 | ### Data Projection
2 | The Pacemaker App does not require an Internet connection and does not share, nor store, any information central.
3 | All information stored by the APP is stored on the users devices.
4 |
5 | ### Broadcasting Information to nearby users.
6 | By the nature of this App, some data is visible to nearby users (shared currently with Bluetooth).
7 | This information includes:
8 | - The current Heart Rate of the user
9 | - The defined 'Heart Rate limit' of the user
10 | - The defined user name
11 | - The userId (which is a random number assigned at first start of the application)
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/settingsPage/SettingsPage.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.settingsPage
2 |
3 | import androidx.compose.runtime.Composable
4 | import io.sellmair.evas.compose.composeValue
5 | import io.sellmair.pacemaker.HeartRateSensorsState
6 | import io.sellmair.pacemaker.MeState
7 |
8 |
9 | @Composable
10 | internal fun SettingsPage() {
11 | val heartRateSensors = HeartRateSensorsState.composeValue().nearbySensors
12 | val me = MeState.composeValue()?.me ?: return
13 |
14 | SettingsPageContent(
15 | me = me,
16 | heartRateSensors = heartRateSensors
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleCentralControkller.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import kotlinx.coroutines.channels.ReceiveChannel
4 |
5 | internal interface BleCentralController {
6 |
7 | fun startScanning()
8 |
9 | val scanResults: ReceiveChannel
10 |
11 | val connectedDevices: ReceiveChannel
12 |
13 | fun createConnectableController(result: ScanResult): BleConnectableController
14 |
15 | interface ScanResult {
16 | val deviceId: BleDeviceId
17 | val rssi: Int
18 | val isConnectable: Boolean
19 | }
20 | }
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBroadcastPackage.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.pacemaker.ble.BleDeviceId
4 | import io.sellmair.pacemaker.model.HeartRate
5 | import io.sellmair.pacemaker.model.Hue
6 | import io.sellmair.pacemaker.model.UserId
7 | import kotlin.time.Instant
8 |
9 | data class PacemakerBroadcastPackage(
10 | val receivedTime: Instant,
11 | val deviceId: BleDeviceId,
12 | val userId: UserId,
13 | val userName: String,
14 | val heartRate: HeartRate,
15 | val heartRateLimit: HeartRate,
16 | val userColorHue: Hue?,
17 | )
18 |
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/UserId.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | import okio.Buffer
4 | import kotlin.jvm.JvmInline
5 | import kotlin.random.Random
6 |
7 | @JvmInline
8 | value class UserId(val value: Long)
9 |
10 | fun UserId.encodeToByteArray(): ByteArray {
11 | return Buffer().writeLong(value).readByteArray()
12 | }
13 |
14 | fun UserId(data: ByteArray): UserId? {
15 | val id = runCatching {
16 | Buffer().write(data).readLong()
17 | }.getOrNull() ?: return null
18 | return UserId(id)
19 | }
20 |
21 | fun randomUserId(): UserId {
22 | return UserId(Random.nextLong())
23 | }
--------------------------------------------------------------------------------
/app-core/src/iosMain/kotlin/io/sellmair/pacemaker/BluetoothState.ios.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import platform.CoreBluetooth.CBCentralManager
4 | import platform.CoreBluetooth.CBCentralManagerStatePoweredOff
5 | import platform.CoreBluetooth.CBManager
6 | import platform.CoreBluetooth.CBManagerAuthorizationAllowedAlways
7 |
8 |
9 | internal actual suspend fun isBluetoothEnabled(): Boolean {
10 | return CBCentralManager().state != CBCentralManagerStatePoweredOff
11 | }
12 |
13 | internal actual suspend fun isBluetoothPermissionGranted(): Boolean {
14 | return CBManager.authorization() == CBManagerAuthorizationAllowedAlways
15 | }
16 |
--------------------------------------------------------------------------------
/utils/src/nativeMain/kotlin/ioUtils.native.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import kotlinx.cinterop.BetaInteropApi
4 | import kotlinx.cinterop.ExperimentalForeignApi
5 | import kotlinx.cinterop.memScoped
6 | import kotlinx.cinterop.toCValues
7 | import okio.FileSystem
8 | import platform.Foundation.NSData
9 | import platform.Foundation.create
10 |
11 | actual fun defaultFileSystem(): FileSystem {
12 | return FileSystem.SYSTEM
13 | }
14 |
15 | @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
16 | fun ByteArray.toNSData(): NSData {
17 | return memScoped {
18 | NSData.create(bytes = this@toNSData.toCValues().ptr, length = size.toULong())
19 | }
20 | }
--------------------------------------------------------------------------------
/app-core/src/androidMain/kotlin/io/sellmair/pacemaker/AndroidContextProvider.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import android.content.Context
4 | import kotlinx.coroutines.currentCoroutineContext
5 | import kotlin.coroutines.CoroutineContext
6 |
7 | internal class AndroidContextProvider(val context: Context) : CoroutineContext.Element {
8 | override val key: CoroutineContext.Key<*> = Key
9 |
10 | companion object Key : CoroutineContext.Key
11 | }
12 |
13 | suspend fun androidContext(): Context {
14 | val provider = currentCoroutineContext()[AndroidContextProvider] ?: error("Missing Android 'Context'")
15 | return provider.context
16 | }
17 |
18 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/ApplicationWindow.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.runtime.Composable
4 | import io.sellmair.pacemaker.ui.mainPage.MainPage
5 | import io.sellmair.pacemaker.ui.settingsPage.SettingsPage
6 | import io.sellmair.pacemaker.ui.timelinePage.TimelinePage
7 |
8 |
9 | @Composable
10 | internal fun ApplicationWindow() {
11 | PacemakerTheme {
12 | PageRouter { page ->
13 | when (page) {
14 | Page.MainPage -> MainPage()
15 | Page.TimelinePage -> TimelinePage()
16 | Page.SettingsPage -> SettingsPage()
17 | }
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleOperation.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import io.sellmair.pacemaker.utils.invoke
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.withContext
6 |
7 | typealias BleSimpleOperation = BleOperation
8 |
9 | interface BleOperation {
10 | val description: String
11 | suspend fun CoroutineScope.invoke(): BleResult
12 | }
13 |
14 | internal suspend infix fun BleQueue.enqueue(operation: BleOperation): BleResult {
15 | return withContext(BleQueue.OperationTitle.invoke(operation.description)) {
16 | enqueue { with(operation) { invoke() } }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/HeartRateMeasurementEvent.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.evas.Event
4 | import io.sellmair.pacemaker.model.HeartRate
5 | import io.sellmair.pacemaker.model.HeartRateSensorId
6 | import kotlin.time.Instant
7 |
8 | data class HeartRateMeasurementEvent(
9 | val heartRate: HeartRate,
10 | val sensorId: HeartRateSensorId,
11 | val time: Instant
12 | ): Event
13 |
14 | fun HeartRateSensorMeasurement.toEvent() : HeartRateMeasurementEvent {
15 | return HeartRateMeasurementEvent(
16 | heartRate = heartRate,
17 | sensorId = sensorInfo.id,
18 | time = receivedTime
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerServiceConstants.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | internal object PacemakerServiceConstants {
4 | const val serviceUuidString = "35b6d6ed-b85f-48e6-8f21-26c9877dbbe8"
5 | const val userIdCharacteristicUuidString = "31F43DE6-19F0-4A2B-925B-77C05E4A05F1"
6 | const val userNameCharacteristicUuidString = "AAA5FD4B-33EB-43B4-84CD-54E53992DAA2"
7 | const val userColorHueCharacteristcUuidString = "cb0cf9a3-2e43-4b98-98e0-49a2e90d7be3"
8 | const val heartRateCharacteristicUuidString = "97F6C220-EEA2-40E1-BF2A-EAD3F086BF2A"
9 | const val heartRateLimitCharacteristicUuidString = "57E050E8-A6FA-4770-8DFD-C5F12DDA8AE7"
10 | }
--------------------------------------------------------------------------------
/utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/conversionUtils.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import okio.Buffer
4 |
5 | fun Int.encodeToByteArray(): ByteArray {
6 | val buffer = Buffer()
7 | buffer.writeInt(this)
8 | return buffer.readByteArray()
9 | }
10 |
11 | fun ByteArray.decodeToInt(): Int {
12 | val buffer = Buffer()
13 | buffer.write(this)
14 | return buffer.readInt()
15 | }
16 |
17 | fun Long.encodeToByteArray(): ByteArray {
18 | val buffer = Buffer()
19 | buffer.writeLong(this)
20 | return buffer.readByteArray()
21 | }
22 |
23 | fun ByteArray.decodeToLong(): Long {
24 | val buffer = Buffer()
25 | buffer.write(this)
26 | return buffer.readLong()
27 | }
--------------------------------------------------------------------------------
/app-core/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("pacemaker-library")
3 | id("app.cash.sqldelight")
4 | }
5 |
6 | pacemaker {
7 | ios()
8 | android()
9 | jvm()
10 |
11 | features {
12 | useSqlDelight {
13 | databases {
14 | create("PacemakerDatabase") {
15 | srcDirs(file("src/sql"))
16 | packageName = "io.sellmair.pacemaker.sql"
17 | }
18 | }
19 | }
20 | }
21 | }
22 |
23 | kotlin {
24 | sourceSets.commonMain.dependencies {
25 | api(project(":models"))
26 | api(project(":utils"))
27 | api(project(":bluetooth"))
28 | implementation(Dependencies.multiplatform_settings)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/Hue.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | import okio.Buffer
4 | import kotlin.jvm.JvmInline
5 | import kotlin.math.absoluteValue
6 |
7 | @JvmInline
8 | value class Hue private constructor(val value: Float) {
9 | companion object {
10 | fun safe(value: Float): Hue {
11 | return Hue(value.absoluteValue % 360f)
12 | }
13 | }
14 | }
15 |
16 |
17 | fun Hue(data: ByteArray): Hue? {
18 | return runCatching {
19 | val bits = Buffer().write(data).readInt()
20 | return Hue.safe(Float.fromBits(bits))
21 | }.getOrNull()
22 | }
23 |
24 | fun Hue.encodeToByteArray(): ByteArray {
25 | return Buffer().writeInt(value.toBits()).readByteArray()
26 | }
--------------------------------------------------------------------------------
/iosApp/Pacemaker.xcodeproj/xcuserdata/sebastiansellmair.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | Copy of Pacemaker.xcscheme
8 |
9 | orderHint
10 | 1
11 |
12 | Pacemaker.xcscheme
13 |
14 | orderHint
15 | 0
16 |
17 | [Fleet] Pacemaker.xcscheme
18 |
19 | isShown
20 |
21 | orderHint
22 | 2
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/spoof-tool/build.gradle.kts:
--------------------------------------------------------------------------------
1 |
2 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
3 |
4 | plugins {
5 | id("pacemaker-application")
6 | }
7 |
8 | pacemaker {
9 | macos()
10 | }
11 |
12 | kotlin {
13 | sourceSets.getByName("commonMain").dependencies {
14 | implementation(project(":bluetooth"))
15 | implementation(Dependencies.coroutines_core)
16 | implementation(Dependencies.okio)
17 | }
18 |
19 | targets.withType().all {
20 | binaries.executable {
21 | entryPoint("io.sellmair.pacemaker.spoof.main")
22 | runTaskProvider?.configure {
23 | this.workingDir(projectDir)
24 | this.standardInput = System.`in`
25 | }
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/GradientBackdrop.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.graphics.Brush
8 | import io.sellmair.pacemaker.ui.MeColor
9 | import io.sellmair.pacemaker.ui.MeColorLight
10 |
11 | @Composable
12 | internal fun GradientBackdrop(modifier: Modifier) {
13 | Box(
14 | modifier = modifier.background(
15 | brush = Brush.verticalGradient(
16 | colors = listOf(MeColor(), MeColorLight()),
17 | startY = 0f,
18 | endY = 500f
19 | )
20 | )
21 | )
22 | }
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/Headline.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.material3.Text
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.graphics.Color
7 | import androidx.compose.ui.text.TextStyle
8 | import androidx.compose.ui.text.font.FontWeight
9 | import androidx.compose.ui.unit.sp
10 |
11 | @Composable
12 | fun Headline(text: String, modifier: Modifier = Modifier) {
13 | Text(
14 | text = text,
15 | style = TextStyle.Headline,
16 | modifier = modifier,
17 | color = Color.Black
18 | )
19 | }
20 |
21 |
22 | val TextStyle.Companion.Headline get() = TextStyle(
23 | fontSize = 28.sp,
24 | fontWeight = FontWeight.Bold
25 | )
26 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | push:
4 | branches: [ master ]
5 | pull_request:
6 | branches: [ master ]
7 |
8 | jobs:
9 | snapshot:
10 | runs-on: macOS-latest
11 | steps:
12 | - name: Checkout sources
13 | uses: actions/checkout@v4
14 |
15 | - uses: actions/setup-java@v4
16 | with:
17 | distribution: corretto
18 | java-version: 17
19 | cache: 'gradle'
20 |
21 | - name: Cache Kotlin Native
22 | uses: actions/cache@v4
23 | with:
24 | path: |
25 | ~/.konan
26 | key: ${{ runner.os }}-konan
27 | restore-keys: ${{ runner.os }}-konan
28 |
29 | - name: Setup Gradle
30 | uses: gradle/actions/setup-gradle@v3
31 |
32 | - name: Build with Gradle
33 | run: ./gradlew check assemble
34 |
--------------------------------------------------------------------------------
/iosApp/Pacemaker/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Pacemaker
4 | //
5 | // Created by Sebastian Sellmair on 13.03.23.
6 | //
7 |
8 | import SwiftUI
9 | import LibPacemaker
10 |
11 | struct ComposeView: UIViewControllerRepresentable {
12 | func makeUIViewController(context: Context) -> UIViewController {
13 | IosPacemakerViewController().create()
14 | }
15 |
16 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
17 | }
18 |
19 | struct ContentView: View {
20 | var body: some View {
21 | ComposeView()
22 | .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
23 | }
24 | }
25 |
26 | struct ContentView_Previews: PreviewProvider {
27 | static var previews: some View {
28 | ContentView()
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app-core/src/iosMain/kotlin/io/sellmair/pacemaker/launchVibrationActor.apple.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.value
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.delay
6 | import kotlinx.coroutines.isActive
7 | import kotlinx.coroutines.launch
8 | import platform.UIKit.UIImpactFeedbackGenerator
9 | import platform.UIKit.UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy
10 | import kotlin.time.Duration.Companion.seconds
11 |
12 | internal fun CoroutineScope.launchVibrationWarningActor() = launch {
13 | while (isActive) {
14 | delay(1.seconds)
15 | if (CriticalGroupState.value() != null && UtteranceState.value() >= UtteranceState.Warnings) {
16 | UIImpactFeedbackGenerator(UIImpactFeedbackStyleHeavy).impactOccurredWithIntensity(1.0)
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/HeartRateSensorServiceDescriptors.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.pacemaker.ble.BleCharacteristicDescriptor
4 | import io.sellmair.pacemaker.ble.BleServiceDescriptor
5 | import io.sellmair.pacemaker.ble.BleUUID
6 |
7 | object HeartRateSensorServiceDescriptors {
8 | val heartRateCharacteristic = BleCharacteristicDescriptor(
9 | name = "Heart Rate",
10 | uuid = BleUUID("00002a37-0000-1000-8000-00805f9b34fb"),
11 | isReadable = false,
12 | isNotificationsEnabled = true
13 | )
14 |
15 | val service = BleServiceDescriptor(
16 | name = "Heart Rate Service",
17 | uuid = BleUUID("0000180D-0000-1000-8000-00805f9b34fb"),
18 | characteristics = setOf(heartRateCharacteristic)
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/iosMain/kotlin/IosPacemakerViewController.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.runtime.CompositionLocalProvider
4 | import androidx.compose.ui.window.ComposeUIViewController
5 | import io.sellmair.evas.compose.LocalEvents
6 | import io.sellmair.evas.compose.LocalStates
7 | import io.sellmair.pacemaker.LocalSessionService
8 | import io.sellmair.pacemaker.backend
9 |
10 |
11 | @Suppress("Unused") // Entry point for iOS application!
12 | object IosPacemakerViewController {
13 | fun create() = ComposeUIViewController {
14 | CompositionLocalProvider(
15 | LocalEvents provides backend.events,
16 | LocalStates provides backend.states,
17 | LocalSessionService provides backend.sessionService
18 | ) {
19 | ApplicationWindow()
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/User.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | import kotlin.math.absoluteValue
4 |
5 | data class User(
6 | val id: UserId,
7 | val name: String,
8 | val isAdhoc: Boolean = false,
9 | )
10 |
11 | val User.nameAbbreviation: String
12 | get() {
13 | if (name.isBlank()) return ""
14 | val parts = name.split(Regex("\\s"))
15 | .filter { it.isNotBlank() }
16 |
17 | if (parts.size >= 2) {
18 | return (parts.first().firstOrNull() ?: "").toString().uppercase() +
19 | (parts.last().firstOrNull() ?: "").toString().uppercase()
20 | }
21 |
22 | return name.take(2)
23 | }
24 |
25 |
26 | fun newUser(id: UserId): User {
27 | return User(id = id, name = "Anonymous ${(id.value % 100).absoluteValue}")
28 | }
--------------------------------------------------------------------------------
/app/src/commonMain/composeResources/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Please stand by 👍
3 | Heart Rate Sensor
4 | connect
5 | disconnect
6 |
7 | Nearby Heart Rate Sensors
8 | Searching for heart rate sensors
9 |
10 | Your heart rate is at: %1$d. The current limit is: %2$d
11 |
12 | Slow down!
13 | You are at %1$d bpm
14 | %1$s is at %2$d bpm
15 |
16 |
--------------------------------------------------------------------------------
/app/src/commonMain/composeResources/values-de/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Bitte warten Sie 👍
3 | Herzfrequenz Sensor
4 | verbinden
5 | trennen
6 |
7 | Herzfrequenzsensoren in der Nähe
8 | Suche nach Herzfrequenzsensoren
9 |
10 | Deine Herzfrequenz beträgt: %1$d. Dein aktuelles Limit beträgt: %2$d
11 |
12 | Langsamer!
13 | Deine eigene Herzfrequenz beträgt %1$d BPM
14 | %1$s läuft mit %2$d BPM
15 |
16 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/withHeartRateSensor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.bluetooth.HeartRateSensor
4 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService
5 | import io.sellmair.pacemaker.bluetooth.toHeartRateSensorId
6 | import io.sellmair.pacemaker.model.HeartRateSensorId
7 | import kotlinx.coroutines.flow.collectLatest
8 | import kotlinx.coroutines.flow.distinctUntilChangedBy
9 | import kotlinx.coroutines.flow.map
10 |
11 | internal suspend fun HeartRateSensorBluetoothService.withHeartRateSensor(
12 | id: HeartRateSensorId, block: suspend (sensor: HeartRateSensor?) -> Unit
13 | ) {
14 | allSensorsNearby
15 | .map { sensors -> sensors.find { it.deviceId.toHeartRateSensorId() == id } }
16 | .distinctUntilChangedBy { sensor -> sensor?.deviceId }
17 | .collectLatest { sensorOrNull -> block(sensorOrNull) }
18 | }
19 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UtteranceState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.State
4 | import io.sellmair.evas.value
5 |
6 | enum class UtteranceState : State {
7 | Silence, Warnings, All;
8 |
9 | fun next(): UtteranceState {
10 | return UtteranceState.entries[(ordinal + 1) % UtteranceState.entries.size]
11 | }
12 |
13 | companion object Key : State.Key {
14 | override val default: UtteranceState = All
15 |
16 | suspend fun shouldBeAnnounced(type: UtteranceEvent.Type): Boolean {
17 | return when (type) {
18 | UtteranceEvent.Type.Info -> UtteranceState.value() >= All
19 | UtteranceEvent.Type.Warning -> UtteranceState.value() >= Warnings
20 | }
21 | }
22 |
23 | suspend fun shouldBeAnnounced(event: UtteranceEvent) = shouldBeAnnounced(event.type)
24 | }
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/app/src/commonMain/composeResources/values-it/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Attendere prego 👍
3 | Sensore di frequenza cardiaca
4 | connetti
5 | disconnetti
6 |
7 | Sensori di frequenza cardiaca nelle vicinanze
8 | Ricerca sensori di frequenza cardiaca
9 |
10 | La tua frequenza cardiaca è di: %1$d. Il limite attuale è: %2$d
11 |
12 | Rallentare!
13 | La tua frequenza cardiaca è di %1$d BPM
14 | %1$s sta correndo alla frequenza di %2$d BPM
15 |
--------------------------------------------------------------------------------
/bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidBluetoothExtensions.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import android.Manifest.permission.*
4 | import android.bluetooth.BluetoothDevice
5 | import android.bluetooth.BluetoothManager
6 | import android.content.Context
7 | import android.content.pm.PackageManager
8 | import androidx.core.content.getSystemService
9 |
10 | internal val BluetoothDevice.deviceId: BleDeviceId get() = BleDeviceId(address)
11 |
12 | fun Context.isBluetoothPermissionGranted(): Boolean {
13 | return checkSelfPermission(BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED &&
14 | checkSelfPermission(BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
15 | checkSelfPermission(BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
16 | }
17 |
18 | fun Context.isBluetoothEnabled(): Boolean {
19 | return getSystemService()?.adapter?.isEnabled == true
20 | }
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/CriticalGroupState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.State
4 | import io.sellmair.evas.collect
5 | import io.sellmair.evas.launchState
6 | import kotlinx.coroutines.CoroutineScope
7 |
8 | data class CriticalGroupState(val criticalMembers: List) : State {
9 | companion object : State.Key {
10 | override val default: CriticalGroupState? = null
11 | }
12 | }
13 |
14 | internal fun CoroutineScope.launchCriticalGroupStateActor() = launchState(CriticalGroupState) {
15 | GroupState.collect { groupState ->
16 | val criticalMembers = groupState.members.filter { userState ->
17 | val heartRateLimit = userState.heartRateLimit ?: return@filter false
18 | userState.heartRate > heartRateLimit
19 | }
20 |
21 | (if (criticalMembers.isNotEmpty()) CriticalGroupState(criticalMembers) else null).emit()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchUtteranceSettingsActor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import com.russhwolf.settings.Settings
4 | import com.russhwolf.settings.set
5 | import io.sellmair.evas.flow
6 | import io.sellmair.evas.set
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.flow.collectLatest
9 | import kotlinx.coroutines.launch
10 |
11 | internal fun CoroutineScope.launchUtteranceSettingsActor(settings: Settings) = launch {
12 | val storedUtterance = settings.getStringOrNull(UtteranceState::class.qualifiedName!!)
13 | if (storedUtterance != null) {
14 | val resolvedStoredUtterance = UtteranceState.entries.find { state -> state.name == storedUtterance }
15 | if (resolvedStoredUtterance != null) {
16 | UtteranceState.set(resolvedStoredUtterance)
17 | }
18 | }
19 |
20 | UtteranceState.flow().collectLatest { state ->
21 | settings[UtteranceState::class.qualifiedName!!] = state.name
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleCentralHardware.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.flow.first
5 | import platform.CoreBluetooth.CBCentralManager
6 | import platform.CoreBluetooth.CBCentralManagerStatePoweredOn
7 |
8 | internal class AppleCentralHardware(
9 | val manager: CBCentralManager,
10 | val delegate: AppleCentralManagerDelegate,
11 | val serviceDescriptor: BleServiceDescriptor
12 | )
13 |
14 | internal suspend fun AppleCentralHardware(
15 | scope: CoroutineScope,
16 | serviceDescriptor: BleServiceDescriptor
17 | ): AppleCentralHardware {
18 | val delegate = AppleCentralManagerDelegate(scope)
19 | val manager = CBCentralManager(delegate, null)
20 | delegate.state.first { it == CBCentralManagerStatePoweredOn }
21 | return AppleCentralHardware(
22 | manager = manager,
23 | delegate = delegate,
24 | serviceDescriptor = serviceDescriptor
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateSensorMeasurement.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("OPT_IN_USAGE")
2 | @file:OptIn(ExperimentalCoroutinesApi::class)
3 |
4 | package io.sellmair.pacemaker
5 |
6 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService
7 | import io.sellmair.pacemaker.bluetooth.toEvent
8 | import io.sellmair.evas.emit
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.Deferred
11 | import kotlinx.coroutines.ExperimentalCoroutinesApi
12 | import kotlinx.coroutines.flow.flatMapMerge
13 | import kotlinx.coroutines.launch
14 |
15 | internal fun CoroutineScope.launchHeartRateSensorMeasurement(
16 | heartRateSensorBluetoothService: Deferred
17 | ) = launch {
18 | /* Connecting our hr receiver and emit hr measurement events */
19 | heartRateSensorBluetoothService.await().newSensorsNearby
20 | .flatMapMerge { sensor -> sensor.heartRate }
21 | .collect { measurement -> measurement.toEvent().emit() }
22 | }
23 |
24 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleConnectableController.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import kotlinx.coroutines.flow.SharedFlow
4 | import kotlinx.coroutines.flow.StateFlow
5 |
6 | interface BleConnectableController {
7 |
8 | val deviceName: String?
9 |
10 | val deviceId: BleDeviceId
11 |
12 | val values: SharedFlow
13 |
14 | val rssi: SharedFlow
15 |
16 | val isConnected: StateFlow
17 |
18 | suspend fun connect(): BleUnit
19 |
20 | suspend fun disconnect(): BleUnit
21 |
22 | suspend fun discoverService(): BleResult>
23 |
24 | suspend fun discoverCharacteristics(): BleUnit
25 |
26 | suspend fun enableNotification(characteristicDescriptor: BleCharacteristicDescriptor): BleResult
27 |
28 | suspend fun readValue(characteristicDescriptor: BleCharacteristicDescriptor): BleResult
29 |
30 | suspend fun writeValue(characteristicDescriptor: BleCharacteristicDescriptor, value: ByteArray): BleResult
31 |
32 | }
--------------------------------------------------------------------------------
/app/src/androidMain/kotlin/Previews.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.Composable
2 | import androidx.compose.ui.tooling.preview.Preview
3 | import io.sellmair.pacemaker.ble.BleConnectable
4 | import io.sellmair.pacemaker.model.HeartRate
5 | import io.sellmair.pacemaker.model.HeartRateSensorId
6 | import io.sellmair.pacemaker.model.User
7 | import io.sellmair.pacemaker.model.UserId
8 | import io.sellmair.pacemaker.ui.settingsPage.HeartRateSensorCard
9 |
10 | @Preview
11 | @Composable
12 | fun HeartRateSensorPreview() {
13 | HeartRateSensorCard(
14 | me = User(
15 | id = UserId(0),
16 | name = "Sebastian Sellmair",
17 | isAdhoc = false,
18 | ),
19 | sensorName = "Polar H10 8B18CA22",
20 | sensorId = HeartRateSensorId("this is a sensor id"),
21 | rssi = 80,
22 | heartRate = HeartRate(64f),
23 | associatedUser = null,
24 | associatedHeartRateLimit = null,
25 | connectIfPossible = false,
26 | connectionState = BleConnectable.ConnectionState.Disconnected
27 | )
28 | }
--------------------------------------------------------------------------------
/models/src/commonMain/kotlin/io/sellmair/pacemaker/model/HeartRate.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.model
2 |
3 | import okio.Buffer
4 | import kotlin.jvm.JvmInline
5 | import kotlin.math.roundToInt
6 |
7 | @JvmInline
8 | value class HeartRate(val value: Float) : Comparable {
9 | constructor(value: Int) : this(value.toFloat())
10 |
11 | override fun compareTo(other: HeartRate): Int {
12 | return this.value.compareTo(other.value)
13 | }
14 |
15 | override fun toString(): String {
16 | return value.roundToInt().toString()
17 | }
18 | }
19 |
20 | fun HeartRate.encodeToByteArray(): ByteArray {
21 | return Buffer().writeInt(value.toBits()).readByteArray()
22 | }
23 |
24 | fun HeartRate(data: ByteArray): HeartRate? {
25 | val bits = runCatching {
26 | Buffer().write(data).readInt()
27 | }.getOrNull() ?: return null
28 |
29 | return HeartRate(Float.fromBits(bits))
30 | }
31 |
32 | infix fun ClosedRange.step(n: Int): IntProgression {
33 | return (start.value.roundToInt()..endInclusive.value.roundToInt()) step n
34 | }
35 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchPacemakerBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService
4 | import io.sellmair.pacemaker.bluetooth.PacemakerBroadcastPackageEvent
5 | import io.sellmair.pacemaker.bluetooth.broadcastPackages
6 | import io.sellmair.pacemaker.model.User
7 | import io.sellmair.evas.emit
8 | import kotlinx.coroutines.CoroutineScope
9 | import kotlinx.coroutines.Deferred
10 | import kotlinx.coroutines.flow.conflate
11 | import kotlinx.coroutines.launch
12 |
13 | internal fun CoroutineScope.launchPacemakerBroadcastReceiver(
14 | userService: UserService, pacemakerBluetoothService: Deferred
15 | ) = launch {
16 | pacemakerBluetoothService.await().broadcastPackages().conflate().collect { received ->
17 | val user = User(id = received.userId, name = received.userName)
18 | userService.saveUser(user)
19 | userService.saveHeartRateLimit(user, received.heartRateLimit)
20 | PacemakerBroadcastPackageEvent(received).emit()
21 | }
22 | }
--------------------------------------------------------------------------------
/utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/Configuration.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import kotlinx.coroutines.currentCoroutineContext
4 | import kotlin.coroutines.CoroutineContext
5 |
6 | suspend fun ConfigurationKey.value(): T? {
7 | currentCoroutineContext()[this]?.let { return it.value }
8 | if (this is ConfigurationKey.WithDefault) return default
9 | return null
10 | }
11 |
12 | suspend fun ConfigurationKey.WithDefault.value(): T {
13 | currentCoroutineContext()[this]?.let { return it.value }
14 | return default
15 | }
16 |
17 | operator fun ConfigurationKey.invoke(value: T): ConfigurationElement {
18 | return ConfigurationElement(this, value)
19 | }
20 |
21 |
22 | data class ConfigurationElement(
23 | override val key: ConfigurationKey, val value: T
24 | ) : CoroutineContext.Element
25 |
26 |
27 | interface ConfigurationKey : CoroutineContext.Key> {
28 | interface WithDefault : ConfigurationKey {
29 | val default: T
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/kotlin/io/sellmair/pacemaker/launchVibrationActor.android.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import android.content.Context
4 | import android.os.CombinedVibration
5 | import android.os.VibrationEffect
6 | import android.os.VibratorManager
7 | import androidx.core.content.getSystemService
8 | import io.sellmair.evas.value
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.delay
11 | import kotlinx.coroutines.isActive
12 | import kotlinx.coroutines.launch
13 | import kotlin.time.Duration.Companion.seconds
14 |
15 | internal fun CoroutineScope.launchVibrationWarningActor(context: Context) = launch {
16 | val vibratorManager = context.getSystemService() ?: return@launch
17 | while (isActive) {
18 | delay(1.seconds)
19 | if (CriticalGroupState.value() != null && UtteranceState.value() >= UtteranceState.Warnings) {
20 | vibratorManager.vibrate(
21 | CombinedVibration.createParallel(
22 | VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)
23 | )
24 | )
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BlePeripheralController.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import kotlinx.coroutines.channels.ReceiveChannel
4 |
5 | internal interface BlePeripheralController {
6 |
7 | fun startAdvertising()
8 |
9 | suspend fun respond(request: WriteRequest, statusCode: BleStatusCode): Boolean
10 |
11 | suspend fun respond(request: ReadRequest, statusCode: BleStatusCode): Boolean
12 |
13 | suspend fun respond(
14 | request: ReadRequest, value: ByteArray, statusCode: BleStatusCode = BleKnownStatusCode.Success
15 | ): Boolean
16 |
17 | suspend fun sendNotification(characteristic: BleCharacteristicDescriptor, value: ByteArray)
18 |
19 |
20 | val writeRequests: ReceiveChannel
21 |
22 | val readRequests: ReceiveChannel
23 |
24 | interface WriteRequest {
25 | val deviceId: BleDeviceId
26 | val characteristicUuid: BleUUID
27 | val value: ByteArray?
28 | }
29 |
30 | interface ReadRequest {
31 | val deviceId: BleDeviceId
32 | val characteristicUuid: BleUUID
33 | val offset: Int
34 | }
35 | }
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchUpdateMeActor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.User
5 | import io.sellmair.evas.Event
6 | import io.sellmair.evas.collectEvents
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.Job
10 | import kotlinx.coroutines.launch
11 |
12 |
13 | sealed class UpdateMeIntent : Event {
14 | data class UpdateMe(val me: User): UpdateMeIntent()
15 | data class UpdateHeartRateLimit(val heartRateLimit: HeartRate): UpdateMeIntent()
16 | }
17 |
18 | internal fun CoroutineScope.launchUpdateMeActor(userService: UserService): Job = launch(Dispatchers.Main.immediate) {
19 | var me = userService.me()
20 | collectEvents { intent ->
21 | when(intent) {
22 | is UpdateMeIntent.UpdateHeartRateLimit -> userService.saveHeartRateLimit(me, intent.heartRateLimit)
23 | is UpdateMeIntent.UpdateMe -> {
24 | me = intent.me
25 | userService.saveUser(intent.me)
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/Logging.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import kotlin.reflect.KClass
4 |
5 | data class LogTag(val name: String) {
6 | fun with(additional: String) = LogTag("$name | $additional")
7 | fun forClass(clazz: KClass<*>) = with(clazz.simpleName.orEmpty())
8 | inline fun forClass() = forClass(T::class)
9 |
10 | override fun toString(): String {
11 | return name
12 | }
13 |
14 | companion object
15 | }
16 |
17 | enum class LogLevel {
18 | Debug, Info, Warn, Error
19 | }
20 |
21 | expect object Log {
22 | operator fun invoke(tag: LogTag, level: LogLevel, message: String, throwable: Throwable? = null)
23 | }
24 |
25 | fun LogTag.debug(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Debug, message, throwable)
26 | fun LogTag.info(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Info, message, throwable)
27 | fun LogTag.warn(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Warn, message, throwable)
28 | fun LogTag.error(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Error, message, throwable)
29 |
30 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateSensorLinkingActor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRateSensorId
4 | import io.sellmair.pacemaker.model.User
5 | import io.sellmair.evas.Event
6 | import io.sellmair.evas.collectEvents
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.Job
10 | import kotlinx.coroutines.launch
11 |
12 | sealed interface HeartRateSensorLinkingIntent : Event {
13 | data class LinkSensor(val user: User, val sensor: HeartRateSensorId) : HeartRateSensorLinkingIntent
14 | data class UnlinkSensor(val sensor: HeartRateSensorId) : HeartRateSensorLinkingIntent
15 | }
16 |
17 |
18 | internal fun CoroutineScope.launchHeartRateSensorLinkingActor(userService: UserService): Job = launch(Dispatchers.Main.immediate) {
19 | collectEvents { intent ->
20 | when (intent) {
21 | is HeartRateSensorLinkingIntent.LinkSensor -> userService.linkSensor(intent.user, intent.sensor)
22 | is HeartRateSensorLinkingIntent.UnlinkSensor -> userService.unlinkSensor(intent.sensor)
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/utils/src/commonTest/kotlin/io/sellmair/pacemaker/utils/ConfigurationTest.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.utils
2 |
3 | import kotlinx.coroutines.test.runTest
4 | import kotlinx.coroutines.withContext
5 | import kotlin.test.*
6 |
7 | class ConfigurationTest {
8 |
9 | object StringKey : ConfigurationKey
10 |
11 | object StringKeyWithDefault : ConfigurationKey.WithDefault {
12 | override val default: String = "foo"
13 | }
14 |
15 | @Test
16 | fun `test - no value attached`() = runTest {
17 | assertNull(StringKey.value())
18 | }
19 |
20 | @Test
21 | fun `test - attach value`() = runTest {
22 | assertNull(StringKey.value())
23 |
24 | withContext(StringKey("foo")) {
25 | assertEquals("foo", StringKey.value())
26 | }
27 |
28 | assertNull(StringKey.value())
29 | }
30 |
31 | @Test
32 | fun `test - attach value - with default`() = runTest {
33 | assertEquals("foo", StringKeyWithDefault.value())
34 |
35 | withContext(StringKey("bar")) {
36 | assertEquals("bar", StringKey.value())
37 | }
38 |
39 | assertEquals("foo", StringKeyWithDefault.value())
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SqlActiveSessionService.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.Session
5 | import io.sellmair.pacemaker.model.User
6 | import io.sellmair.pacemaker.utils.value
7 | import kotlin.time.Instant
8 |
9 | internal class SqlActiveSessionService(
10 | override val session: Session,
11 | private val database: SafePacemakerDatabase
12 | ) : ActiveSessionService {
13 |
14 | override suspend fun stop(): Unit = database {
15 | sessionQueries.endSession(SessionService.SessionClock.value().now().toString(), session.id.value)
16 | }
17 |
18 | override suspend fun save(
19 | user: User,
20 | heartRate: HeartRate,
21 | heartRateLimit: HeartRate?,
22 | measurementTime: Instant
23 | ): Unit = database {
24 | sessionQueries.saveHeartRateMeasurement(
25 | session_id = session.id.value,
26 | user_id = user.id.value,
27 | time = measurementTime.toString(),
28 | heart_rate = heartRate.value.toDouble(),
29 | heart_rate_limit = heartRateLimit?.value?.toDouble()
30 | )
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SessionsState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.Session
4 | import io.sellmair.evas.State
5 | import io.sellmair.evas.StateProducerStarted
6 | import io.sellmair.evas.launchState
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Job
9 | import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily
10 | import kotlinx.coroutines.flow.distinctUntilChanged
11 | import kotlinx.coroutines.flow.emitAll
12 | import kotlinx.coroutines.flow.map
13 |
14 | data class SessionsState(val sessions: List) : State {
15 | companion object Key : State.Key {
16 | override val default: SessionsState = SessionsState(emptyList())
17 | }
18 | }
19 |
20 | internal fun CoroutineScope.launchSessionStateActor(sessionService: SessionService): Job = launchState(
21 | SessionsState.Key, started = StateProducerStarted.Lazily
22 | ) {
23 | val sessionsStateFlow = sessionService.sessionsFlow
24 | .map { sessionServices -> sessionServices.map { it.session }.sortedBy { it.startTime } }
25 | .map { sessions -> SessionsState(sessions) }
26 | .distinctUntilChanged()
27 |
28 | emitAll(sessionsStateFlow)
29 | }
30 |
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothConnection.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("FunctionName")
2 |
3 | package io.sellmair.pacemaker.bluetooth
4 |
5 | import io.sellmair.pacemaker.ble.BleConnection
6 | import io.sellmair.pacemaker.ble.BleDeviceId
7 | import io.sellmair.pacemaker.ble.BleReceivedValue
8 | import kotlinx.coroutines.CoroutineScope
9 | import kotlinx.coroutines.flow.SharedFlow
10 | import kotlin.coroutines.CoroutineContext
11 |
12 | interface PacemakerBluetoothConnection : CoroutineScope {
13 | val deviceId: BleDeviceId
14 | val receivedValues: SharedFlow
15 | }
16 |
17 | internal interface WritablePacemakerBluetoothConnection : PacemakerBluetoothConnection, PacemakerBluetoothWritable
18 |
19 | internal fun PacemakerConnection(connection: BleConnection): WritablePacemakerBluetoothConnection =
20 | object : WritablePacemakerBluetoothConnection,
21 | PacemakerBluetoothWritable by PacemakerBluetoothWritable(connection) {
22 | override val deviceId: BleDeviceId = connection.deviceId
23 | override val receivedValues: SharedFlow = connection.receivedValues
24 | override val coroutineContext: CoroutineContext = connection.scope.coroutineContext
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/ColorHueSlider.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.material3.Slider
4 | import androidx.compose.material3.SliderDefaults
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import io.sellmair.evas.compose.EvasLaunching
8 | import io.sellmair.evas.compose.composeValue
9 | import io.sellmair.evas.emitAsync
10 | import io.sellmair.pacemaker.MeColorIntent
11 | import io.sellmair.pacemaker.MeColorState
12 | import io.sellmair.pacemaker.UserColors
13 | import io.sellmair.pacemaker.ui.toColor
14 |
15 | @Composable
16 | fun ColorHueSlider(
17 | modifier: Modifier = Modifier
18 | ) {
19 | val meColor = MeColorState.composeValue()?.color ?: return
20 |
21 | Slider(
22 | valueRange = 0f..360f,
23 | value = meColor.hue,
24 | onValueChange = EvasLaunching { newHue ->
25 | MeColorIntent.ChangeHue(newHue).emitAsync()
26 | },
27 | modifier = modifier,
28 | colors = SliderDefaults.colors(
29 | thumbColor = meColor.toColor(),
30 | activeTrackColor = UserColors.fromHueLight(meColor.hue).toColor(),
31 | inactiveTrackColor = meColor.toColor()
32 | )
33 | )
34 | }
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/colorUtils.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.Hue
4 | import io.sellmair.pacemaker.model.UserId
5 | import kotlin.math.absoluteValue
6 |
7 | object UserColors {
8 | const val saturation = .5f
9 | const val lightness = .4f
10 | const val saturationLight = .7f
11 | const val lightnessLight = .75f
12 |
13 |
14 | fun default(userId: UserId?): HSLColor = fromHue(defaultHue(userId))
15 |
16 | fun defaultHue(userId: UserId?): Float = userId.hashCode().toFloat().absoluteValue % 360f
17 |
18 | fun fromHue(hue: Hue): HSLColor = fromHue(hue.value)
19 |
20 | fun fromHue(hue: Float) = HSLColor(
21 | hue = hue, saturation = saturation, lightness = lightness
22 | )
23 |
24 | fun fromHueLight(hue: Float) = HSLColor(
25 | hue = hue, saturation = saturationLight, lightness = lightnessLight
26 | )
27 | }
28 |
29 | fun HSLColor.toUserLight() = UserColors.fromHueLight(hue)
30 |
31 | data class HSLColor(
32 | val hue: Float,
33 | val saturation: Float,
34 | val lightness: Float
35 | )
36 |
37 |
38 | val UserState.displayColor: HSLColor
39 | get() = color
40 |
41 | val UserState.displayColorLight: HSLColor
42 | get() = color.toUserLight()
43 |
44 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UserService.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.HeartRateSensorId
5 | import io.sellmair.pacemaker.model.User
6 | import io.sellmair.pacemaker.model.UserId
7 | import io.sellmair.pacemaker.utils.ConfigurationKey
8 | import kotlinx.coroutines.flow.Flow
9 |
10 | interface UserService {
11 | suspend fun me(): User
12 | suspend fun saveUser(user: User)
13 | suspend fun deleteUser(user: User)
14 |
15 | suspend fun findUser(userId: UserId): User?
16 | suspend fun findUser(sensorId: HeartRateSensorId): User?
17 | fun findUserFlow(sensorId: HeartRateSensorId): Flow
18 |
19 | suspend fun linkSensor(user: User, sensorId: HeartRateSensorId)
20 | suspend fun unlinkSensor(sensorId: HeartRateSensorId)
21 |
22 | suspend fun findHeartRateLimit(user: User): HeartRate?
23 | fun findHeartRateLimitFlow(user: User): Flow
24 | suspend fun saveHeartRateLimit(user: User, limit: HeartRate)
25 |
26 | val onChange: Flow
27 | val onSaveUser: Flow
28 |
29 | object NewUserHeartRateLimit : ConfigurationKey.WithDefault {
30 | override val default: HeartRate = HeartRate(145)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/coroutineUtils.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.rememberCoroutineScope
5 | import io.sellmair.evas.compose.eventsOrNull
6 | import io.sellmair.evas.compose.statesOrNull
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.launch
10 | import kotlin.coroutines.EmptyCoroutineContext
11 |
12 | @Composable
13 | fun Launching(action: suspend () -> Unit): () -> Unit {
14 | val scope = rememberPacemakerCoroutineScope()
15 | return {
16 | scope.launch {
17 | action()
18 | }
19 | }
20 | }
21 |
22 |
23 | @Composable
24 | fun Launching(action: suspend (value: T) -> Unit): (T) -> Unit {
25 | val scope = rememberPacemakerCoroutineScope()
26 | return { value ->
27 | scope.launch {
28 | action(value)
29 | }
30 | }
31 | }
32 |
33 |
34 | @Composable
35 | fun rememberPacemakerCoroutineScope(): CoroutineScope {
36 | val eventBus = eventsOrNull() ?: EmptyCoroutineContext
37 | val stateBus = statesOrNull() ?: EmptyCoroutineContext
38 | return rememberCoroutineScope { Dispatchers.Main.immediate + eventBus + stateBus }
39 | }
40 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SessionService.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import androidx.compose.runtime.staticCompositionLocalOf
4 | import io.sellmair.pacemaker.model.HeartRate
5 | import io.sellmair.pacemaker.model.Session
6 | import io.sellmair.pacemaker.model.User
7 | import io.sellmair.pacemaker.utils.ConfigurationKey
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlin.time.Clock
10 | import kotlin.time.Instant
11 |
12 | val LocalSessionService = staticCompositionLocalOf { null }
13 |
14 | interface SessionService {
15 | object SessionClock : ConfigurationKey.WithDefault {
16 | override val default: Clock = Clock.System
17 | }
18 |
19 | suspend fun createSession(): ActiveSessionService
20 | suspend fun getSessions(): List
21 |
22 | val sessionsFlow: Flow>
23 | }
24 |
25 | interface ActiveSessionService {
26 | val session: Session
27 | suspend fun stop()
28 | suspend fun save(
29 | user: User, heartRate: HeartRate, heartRateLimit: HeartRate?, measurementTime: Instant,
30 | )
31 | }
32 |
33 | interface StoredSessionService {
34 | val session: Session
35 | suspend fun getUsers(): List
36 | suspend fun getHeartRateMeasurements(user: User): List
37 | }
38 |
--------------------------------------------------------------------------------
/app-core/src/sql/io/sellmair/pacemaker/Session.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE db_session (
2 | id INTEGER PRIMARY KEY NOT NULL,
3 | start_time TEXT NOT NULL,
4 | end_time TEXT
5 | );
6 |
7 | CREATE TABLE db_session_heart_rate(
8 | session_id INTEGER NOT NULL,
9 | user_id INTEGER NOT NULL,
10 | time TEXT NOT NULL,
11 | heart_rate REAL NOT NULL,
12 | heart_rate_limit REAL,
13 | FOREIGN KEY (user_id) REFERENCES db_user(id),
14 | FOREIGN KEY (session_id) REFERENCES db_session(id)
15 | );
16 |
17 | CREATE INDEX db_heart_rate_measurement_session_index ON db_session_heart_rate(session_id);
18 |
19 | CREATE INDEX db_heart_rate_measurement_user_index ON db_session_heart_rate(user_id);
20 |
21 | newSession:
22 | INSERT INTO db_session(start_time) VALUES (?);
23 |
24 | lastSessionId:
25 | SELECT last_insert_rowid();
26 |
27 | endSession:
28 | UPDATE db_session SET end_time=? WHERE id =?;
29 |
30 | allSessions:
31 | SELECT * FROM db_session;
32 |
33 | saveHeartRateMeasurement:
34 | INSERT INTO db_session_heart_rate(
35 | session_id,
36 | user_id,
37 | time,
38 | heart_rate,
39 | heart_rate_limit
40 | ) VALUES (?, ?, ?, ?, ?);
41 |
42 | findHeartRateMeasurements:
43 | SELECT * FROM db_session_heart_rate WHERE session_id=?;
44 |
45 | findUsers:
46 | SELECT DISTINCT (user_id) FROM db_session_heart_rate WHERE session_id=?;
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | pluginManagement {
4 | repositories {
5 | mavenCentral()
6 | gradlePluginPortal()
7 | google()
8 | maven("https://repo.sellmair.io")
9 |
10 | }
11 | }
12 |
13 | dependencyResolutionManagement {
14 | versionCatalogs.register("deps") {
15 | from(files("dependencies.toml"))
16 | }
17 | }
18 |
19 | dependencyResolutionManagement {
20 | repositories {
21 | mavenCentral()
22 | google {
23 | mavenContent {
24 | includeGroupByRegex(".*android.*")
25 | includeGroupByRegex(".*androidx.*")
26 | }
27 | }
28 |
29 | maven("https://androidx.dev/storage/compose-compiler/repository") {
30 | mavenContent {
31 | includeGroupByRegex("androidx.compose.*")
32 | }
33 | }
34 |
35 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") {
36 | mavenContent {
37 | includeGroupByRegex(".*compose.*")
38 | }
39 | }
40 |
41 | maven("https://repo.sellmair.io")
42 | }
43 | }
44 |
45 | include(":utils")
46 | include(":models")
47 | include(":bluetooth")
48 | include(":bluetooth-core")
49 | include(":spoof-tool")
50 | include(":app")
51 | include(":app-core")
52 |
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothWritableImpl.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.pacemaker.ble.BleWritable
4 | import io.sellmair.pacemaker.model.HeartRate
5 | import io.sellmair.pacemaker.model.Hue
6 | import io.sellmair.pacemaker.model.User
7 | import io.sellmair.pacemaker.model.encodeToByteArray
8 |
9 |
10 | internal fun PacemakerBluetoothWritable(underlying: BleWritable) = object : PacemakerBluetoothWritable {
11 | override suspend fun setUser(user: User) {
12 | underlying.setValue(PacemakerServiceDescriptors.userNameCharacteristic, user.name.encodeToByteArray())
13 | underlying.setValue(PacemakerServiceDescriptors.userIdCharacteristic, user.id.encodeToByteArray())
14 | }
15 |
16 | override suspend fun setHeartRate(heartRate: HeartRate) {
17 | underlying.setValue(PacemakerServiceDescriptors.heartRateCharacteristic, heartRate.encodeToByteArray())
18 | }
19 |
20 | override suspend fun setHeartRateLimit(heartRate: HeartRate) {
21 | underlying.setValue(PacemakerServiceDescriptors.heartRateLimitCharacteristic, heartRate.encodeToByteArray())
22 | }
23 |
24 | override suspend fun setColorHue(hue: Hue) {
25 | underlying.setValue(PacemakerServiceDescriptors.userColorHueCharacteristic, hue.encodeToByteArray())
26 | }
27 | }
--------------------------------------------------------------------------------
/app-core/src/sql/io/sellmair/pacemaker/User.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE db_user (
2 | id INTEGER PRIMARY KEY NOT NULL,
3 | name TEXT NOT NULL,
4 | is_adhoc INTEGER NOT NULL
5 | );
6 |
7 | CREATE TABLE db_sensor(
8 | id TEXT PRIMARY KEY NOT NULL,
9 | user_id INTEGER NOT NULL,
10 | FOREIGN KEY(user_id) REFERENCES db_user(id)
11 | );
12 |
13 | CREATE TABLE db_heart_rate_limit(
14 | user_id INTEGER PRIMARY KEY NOT NULL ,
15 | heart_rate_limit REAL,
16 | FOREIGN KEY (user_id) REFERENCES db_user(id)
17 | );
18 |
19 | findUserById:
20 | SELECT * FROM db_user WHERE id = ?;
21 |
22 | findUserBySensorId:
23 | SELECT * FROM db_user
24 | LEFT JOIN db_sensor ON db_sensor.user_id = db_user.id
25 | WHERE db_sensor.id = ?;
26 |
27 | findUserSettingsForUserId:
28 | SELECT * FROM db_heart_rate_limit WHERE user_id = ?;
29 |
30 | saveUser:
31 | INSERT OR REPLACE INTO db_user VALUES ?;
32 |
33 | saveHeartRateLimit:
34 | INSERT OR REPLACE INTO db_heart_rate_limit VALUES ?;
35 |
36 | saveSensor:
37 | INSERT OR REPLACE INTO db_sensor VALUES ?;
38 |
39 | deleteUser:
40 | DELETE FROM db_user WHERE db_user.id = ?;
41 |
42 | deleteHeartRateLimit:
43 | DELETE FROM db_heart_rate_limit WHERE db_heart_rate_limit.user_id = ?;
44 |
45 | deleteUserSensors:
46 | DELETE FROM db_sensor WHERE db_sensor.user_id = ?;
47 |
48 | deleteSensor:
49 | DELETE FROM db_sensor WHERE db_sensor.id = ?;
50 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleStatusCode.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | fun BleStatusCode(value: Int): BleStatusCode {
4 | return BleKnownStatusCode.byCode[value] ?: BleUnknownStatusCode(value)
5 | }
6 |
7 | sealed interface BleStatusCode {
8 | val code: Int
9 | }
10 |
11 | val BleStatusCode.isSuccess get() = this == BleKnownStatusCode.Success
12 | fun BleStatusCode.toInt() = code
13 | fun BleStatusCode.toLong() = code.toLong()
14 |
15 | enum class BleKnownStatusCode(override val code: Int) : BleStatusCode {
16 | Success(0),
17 | IllegalOffset(7),
18 | GattError(133),
19 | InternalError(129),
20 | UnknownAttribute(0x010A);
21 |
22 | override fun toString(): String {
23 | return "BleStatusCode($name)"
24 | }
25 |
26 | companion object {
27 | val values = values().toList()
28 | val codes = values.map { it.code }.toSet()
29 | val byCode = values.associateBy { it.code }
30 | }
31 | }
32 |
33 | class BleUnknownStatusCode internal constructor(override val code: Int) : BleStatusCode {
34 | init {
35 | val knownStatusCode = BleKnownStatusCode.byCode[code]
36 | if (knownStatusCode != null) {
37 | throw IllegalArgumentException("code '$code' is known as '$knownStatusCode'")
38 | }
39 | }
40 |
41 | override fun toString(): String {
42 | return "BleStatusCode($code)"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app-core/src/iosMain/kotlin/io/sellmair/pacemaker/launchSpeechSynthesizer.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalForeignApi::class)
2 |
3 | package io.sellmair.pacemaker
4 |
5 | import io.sellmair.evas.events
6 | import kotlinx.cinterop.ExperimentalForeignApi
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.delay
9 | import kotlinx.coroutines.flow.conflate
10 | import kotlinx.coroutines.launch
11 | import platform.AVFAudio.*
12 | import kotlin.time.Duration.Companion.milliseconds
13 |
14 | private val synthesizer = AVSpeechSynthesizer()
15 |
16 | @OptIn(ExperimentalForeignApi::class)
17 | internal fun CoroutineScope.launchSpeechSynthesizer() = launch {
18 | val audioSession = AVAudioSession.sharedInstance()
19 | audioSession.setCategory(
20 | AVAudioSessionCategoryPlayback,
21 | AVAudioSessionCategoryOptionDuckOthers or AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers,
22 | null
23 | )
24 | audioSession.setMode(AVAudioSessionModeVoicePrompt, null)
25 |
26 | events().conflate().collect { event ->
27 | if (!UtteranceState.shouldBeAnnounced(event)) return@collect
28 | val utterance = AVSpeechUtterance.speechUtteranceWithString(event.message)
29 | utterance.rate = AVSpeechUtteranceDefaultSpeechRate
30 | synthesizer.speakUtterance(utterance)
31 | while (synthesizer.isSpeaking()) delay(250.milliseconds)
32 | audioSession.setActive(false, null)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/MeColorState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import com.russhwolf.settings.Settings
4 | import com.russhwolf.settings.set
5 | import io.sellmair.evas.Event
6 | import io.sellmair.evas.State
7 | import io.sellmair.evas.collectEvents
8 | import io.sellmair.evas.launchState
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.Dispatchers
11 |
12 | class MeColorState(val color: HSLColor) : State {
13 | companion object Key : State.Key {
14 | override val default: MeColorState? = null
15 | }
16 | }
17 |
18 | sealed class MeColorIntent : Event {
19 | data class ChangeHue(val hue: Float) : MeColorIntent()
20 | }
21 |
22 | internal fun CoroutineScope.launchMeColorStateActor(
23 | settings: Settings
24 | ) = launchState(MeColorState, context = Dispatchers.Main.immediate) {
25 | val initialHue = settings.storedUserHue ?: UserColors.defaultHue(settings.meId)
26 | MeColorState(UserColors.fromHue(initialHue)).emit()
27 |
28 | collectEvents { event ->
29 | MeColorState(UserColors.fromHue(event.hue)).emit()
30 | settings.storedUserHue = event.hue
31 | }
32 | }
33 |
34 | private const val hueSettingsKey = "me.hue"
35 |
36 | private var Settings.storedUserHue: Float?
37 | get() = getFloatOrNull(hueSettingsKey)
38 | set(value) {
39 | if (value != null) set(hueSettingsKey, value)
40 | else remove(hueSettingsKey)
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/jvmMain/kotlin/io/sellmair/pacemaker/ui/main.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.material.Text
4 | import androidx.compose.ui.Alignment
5 | import androidx.compose.ui.unit.DpSize
6 | import androidx.compose.ui.unit.dp
7 | import androidx.compose.ui.window.WindowPosition
8 | import androidx.compose.ui.window.WindowState
9 | import androidx.compose.ui.window.singleWindowApplication
10 | import io.sellmair.evas.compose.installEvas
11 | import io.sellmair.evas.eventsOrThrow
12 | import io.sellmair.evas.statesOrThrow
13 | import io.sellmair.pacemaker.JvmApplicationBackend
14 | import io.sellmair.pacemaker.JvmHeartRateSensorBluetoothService
15 | import io.sellmair.pacemaker.launchApplicationBackend
16 | import kotlinx.coroutines.CoroutineScope
17 | import kotlinx.coroutines.Dispatchers
18 | import kotlinx.coroutines.SupervisorJob
19 |
20 | val appScope = CoroutineScope(
21 | SupervisorJob() + Dispatchers.Main +
22 | JvmApplicationBackend.events + JvmApplicationBackend.states
23 | )
24 |
25 | fun main() {
26 |
27 | JvmApplicationBackend.launchApplicationBackend(appScope)
28 |
29 |
30 | singleWindowApplication(
31 | title = "Pacemaker",
32 | alwaysOnTop = true,
33 | state = WindowState(position = WindowPosition.Aligned(Alignment.TopEnd), size = DpSize(400.dp, 800.dp)),
34 | ) {
35 | installEvas(appScope.coroutineContext.eventsOrThrow, appScope.coroutineContext.statesOrThrow) {
36 | ApplicationWindow()
37 | }
38 | }
39 | }
40 |
41 |
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleBle.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import io.sellmair.pacemaker.ble.impl.BleCentralServiceImpl
4 | import io.sellmair.pacemaker.ble.impl.BlePeripheralServiceImpl
5 | import kotlinx.coroutines.*
6 |
7 | fun AppleBle(): Ble = AppleBleImpl()
8 |
9 | private class AppleBleImpl : Ble {
10 |
11 | override val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
12 |
13 | private val queue = BleQueue(coroutineScope.coroutineContext.job)
14 |
15 | override suspend fun createCentralService(service: BleServiceDescriptor): BleCentralService {
16 | return withContext(coroutineScope.coroutineContext) {
17 | val centralHardware = AppleCentralHardware(coroutineScope, service)
18 | val controller = AppleCentralController(coroutineScope, centralHardware)
19 | BleCentralServiceImpl(coroutineScope, queue, controller, service)
20 | }
21 | }
22 |
23 | override suspend fun createPeripheralService(service: BleServiceDescriptor): BlePeripheralService {
24 | return withContext(coroutineScope.coroutineContext) {
25 | val peripheralHardware = ApplePeripheralHardware(coroutineScope, service)
26 | val peripheralController = ApplePeripheralController(coroutineScope, peripheralHardware)
27 | BlePeripheralServiceImpl(queue, peripheralController, service, coroutineScope)
28 | }
29 | }
30 |
31 | override fun close() {
32 | coroutineScope.cancel()
33 | }
34 | }
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SafePacemakerDatabase.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.sql.PacemakerDatabase
4 | import io.sellmair.pacemaker.utils.ConfigurationKey
5 | import io.sellmair.pacemaker.utils.value
6 | import kotlinx.coroutines.CoroutineDispatcher
7 | import kotlinx.coroutines.DelicateCoroutinesApi
8 | import kotlinx.coroutines.ExperimentalCoroutinesApi
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlinx.coroutines.flow.emitAll
11 | import kotlinx.coroutines.flow.flowOn
12 | import kotlinx.coroutines.newSingleThreadContext
13 | import kotlinx.coroutines.withContext
14 |
15 | @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
16 | private val databaseBackgroundThread: CoroutineDispatcher = newSingleThreadContext("database")
17 |
18 | internal object DatabaseBackgroundDispatcher : ConfigurationKey.WithDefault {
19 | override val default: CoroutineDispatcher = databaseBackgroundThread
20 | }
21 |
22 | internal class SafePacemakerDatabase(
23 | factory: () -> PacemakerDatabase
24 | ) {
25 | private val database by lazy(factory)
26 |
27 | suspend operator fun invoke(block: suspend PacemakerDatabase.() -> T): T {
28 | return withContext(DatabaseBackgroundDispatcher.value()) {
29 | block(database)
30 | }
31 | }
32 |
33 | fun flow(block: PacemakerDatabase.() -> Flow): Flow {
34 | return kotlinx.coroutines.flow.flow {
35 | emitAll(database.block().flowOn(DatabaseBackgroundDispatcher.value()))
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/OnHeartRateScalePosition.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.Modifier
5 | import androidx.compose.ui.layout.Layout
6 | import io.sellmair.pacemaker.model.HeartRate
7 | import kotlin.math.roundToInt
8 |
9 | enum class ScaleSide {
10 | Left, Right
11 | }
12 |
13 | @Composable
14 | internal fun OnHeartRateScalePosition(
15 | heartRate: HeartRate,
16 | range: ClosedRange,
17 | modifier: Modifier = Modifier,
18 | horizontalCenterBias: Float = .5f,
19 | side: ScaleSide = ScaleSide.Right,
20 | content: @Composable () -> Unit
21 | ) {
22 | Layout(modifier = modifier, content = content) { measurables, constraints ->
23 | val placeables = measurables.map { measurable -> measurable.measure(constraints) }
24 | layout(constraints.maxWidth, constraints.maxHeight) {
25 | placeables.forEach { placeable ->
26 | placeable.placeRelative(
27 | x = when (side) {
28 | ScaleSide.Right -> constraints.maxWidth.times(horizontalCenterBias).roundToInt()
29 | ScaleSide.Left -> (constraints.maxWidth.times(horizontalCenterBias) - placeable.width).roundToInt()
30 | },
31 | y = yOfHeartRate(
32 | HeartRate(heartRate.value), range, constraints.maxHeight.toFloat()
33 | ).roundToInt() - placeable.height / 2
34 | )
35 | }
36 | }
37 | }
38 | }
39 |
40 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/GroupHeartRateScale.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.mainPage
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.key
5 | import io.sellmair.evas.compose.composeValue
6 | import io.sellmair.evas.emit
7 | import io.sellmair.pacemaker.GroupState
8 | import io.sellmair.pacemaker.UpdateMeIntent
9 | import io.sellmair.pacemaker.model.HeartRate
10 | import io.sellmair.pacemaker.ui.widget.*
11 |
12 | @Composable
13 | fun GroupHeartRateOverview(
14 | range: ClosedRange = HeartRate(40)..HeartRate(200f),
15 | ) {
16 | GroupHeartRateOverview(
17 | state = GroupState.composeValue(),
18 | range = range
19 | )
20 | }
21 |
22 | @Composable
23 | internal fun GroupHeartRateOverview(
24 | state: GroupState?,
25 | range: ClosedRange = HeartRate(40)..HeartRate(200f),
26 | ) {
27 | HeartRateScale(range = range) {
28 | state?.members.orEmpty().forEach { memberState ->
29 | key(memberState.user.id.value) {
30 | MemberHeartRateIndicator(memberState, range)
31 |
32 | if (memberState.isMe) {
33 | ChangeableMemberHeartRateLimit(
34 | state = memberState,
35 | range = range,
36 | onLimitChanged = Launching { heartRate ->
37 | UpdateMeIntent.UpdateHeartRateLimit(heartRate).emit()
38 | }
39 | )
40 | } else {
41 | MemberHeartRateLimit(memberState, range)
42 | }
43 | }
44 | }
45 | }
46 | }
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothService.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("OPT_IN_USAGE")
2 |
3 | package io.sellmair.pacemaker.bluetooth
4 |
5 | import io.sellmair.pacemaker.ble.Ble
6 | import kotlinx.coroutines.flow.SharedFlow
7 | import kotlinx.coroutines.flow.SharingStarted
8 | import kotlinx.coroutines.flow.flattenMerge
9 | import kotlinx.coroutines.flow.flowOf
10 | import kotlinx.coroutines.flow.shareIn
11 |
12 | interface PacemakerBluetoothService {
13 | val newConnections: SharedFlow
14 | val allConnections: SharedFlow>
15 | suspend fun write(write: suspend PacemakerBluetoothWritable.() -> Unit)
16 | }
17 |
18 | suspend fun PacemakerBluetoothService(ble: Ble): PacemakerBluetoothService {
19 | val peripheral = PacemakerPeripheralBluetoothService(ble)
20 | val central = PacemakerCentralBluetoothService(ble)
21 |
22 | return object : PacemakerBluetoothService {
23 | override val newConnections: SharedFlow =
24 | flowOf(peripheral.newConnections, central.newConnections).flattenMerge()
25 | .shareIn(ble.coroutineScope, SharingStarted.Eagerly)
26 |
27 | override val allConnections: SharedFlow> =
28 | flowOf(peripheral.allConnections, central.allConnections)
29 | .flattenMerge().shareIn(ble.coroutineScope, SharingStarted.Eagerly)
30 |
31 | override suspend fun write(write: suspend PacemakerBluetoothWritable.() -> Unit) {
32 | peripheral.write(write)
33 | central.write(write)
34 | }
35 | }
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleCBMutableServiceFactory.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import platform.CoreBluetooth.CBAttributePermissionsReadable
4 | import platform.CoreBluetooth.CBAttributePermissionsWriteable
5 | import platform.CoreBluetooth.CBCharacteristicPropertyNotify
6 | import platform.CoreBluetooth.CBCharacteristicPropertyRead
7 | import platform.CoreBluetooth.CBCharacteristicPropertyWrite
8 | import platform.CoreBluetooth.CBCharacteristicPropertyWriteWithoutResponse
9 | import platform.CoreBluetooth.CBMutableCharacteristic
10 | import platform.CoreBluetooth.CBMutableService
11 |
12 |
13 | internal fun CBMutableService(descriptor: BleServiceDescriptor): CBMutableService {
14 | val service = CBMutableService(descriptor.uuid, true)
15 | val characteristics = descriptor.characteristics.map { characteristic ->
16 | CBMutableCharacteristic(
17 | type = characteristic.uuid,
18 | properties = ((CBCharacteristicPropertyRead.takeIf { characteristic.isReadable } ?: 0.toULong()) or
19 | (CBCharacteristicPropertyNotify.takeIf { characteristic.isNotificationsEnabled } ?: 0.toULong())) or
20 | (CBCharacteristicPropertyWrite.takeIf { characteristic.isWritable } ?: 0.toULong()) or
21 | (CBCharacteristicPropertyWriteWithoutResponse.takeIf { characteristic.isWritable } ?: 0.toULong()),
22 | value = null,
23 | permissions = CBAttributePermissionsReadable or
24 | (CBAttributePermissionsWriteable.takeIf { characteristic.isWritable } ?: 0.toULong())
25 | )
26 | }
27 | service.setCharacteristics(characteristics)
28 | return service
29 | }
--------------------------------------------------------------------------------
/.run/🔥app [jvm].run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 |
18 |
19 |
20 | false
21 | true
22 | false
23 |
24 |
25 | $PROJECT_DIR$/app
26 | hotRunJvm
27 | true
28 |
29 |
30 | false
31 | false
32 | false
33 | false
34 |
35 |
36 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/HeartRateSensorsState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import androidx.compose.runtime.Immutable
4 | import io.sellmair.evas.State
5 | import io.sellmair.evas.launchState
6 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService
7 | import io.sellmair.pacemaker.bluetooth.toHeartRateSensorId
8 | import io.sellmair.pacemaker.model.HeartRateSensorId
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.Deferred
11 | import kotlinx.coroutines.flow.emitAll
12 | import kotlinx.coroutines.flow.map
13 |
14 | @Immutable
15 | data class HeartRateSensorsState(val nearbySensors: List) : State {
16 | @Immutable
17 | data class HeartRateSensorInfo(
18 | val id: HeartRateSensorId,
19 | val name: String?
20 | )
21 |
22 | companion object Key : State.Key {
23 | override val default: HeartRateSensorsState
24 | get() = HeartRateSensorsState(emptyList())
25 | }
26 | }
27 |
28 | internal fun CoroutineScope.launchHeartRateSensorsStateActor(
29 | bluetoothService: Deferred
30 | ) = launchState(HeartRateSensorsState) {
31 | emitAll(bluetoothService.await()
32 | .allSensorsNearby
33 | .map { sensors ->
34 | val nearbySensors = sensors
35 | .sortedBy { it.deviceId.value }
36 | .map { sensor ->
37 | HeartRateSensorsState.HeartRateSensorInfo(
38 | id = sensor.deviceId.toHeartRateSensorId(),
39 | name = sensor.deviceName
40 | )
41 | }
42 | HeartRateSensorsState(nearbySensors)
43 | })
44 | }
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/ApplePeripheralHardware.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import io.sellmair.pacemaker.ble.ApplePeripheralHardware.Companion.log
4 | import io.sellmair.pacemaker.utils.LogTag
5 | import io.sellmair.pacemaker.utils.error
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.flow.first
8 | import platform.CoreBluetooth.CBMutableService
9 | import platform.CoreBluetooth.CBPeripheralManager
10 | import platform.CoreBluetooth.CBPeripheralManagerStatePoweredOn
11 |
12 | internal class ApplePeripheralHardware(
13 | val manager: CBPeripheralManager,
14 | val delegate: ApplePeripheralManagerDelegate,
15 | val service: CBMutableService,
16 | val serviceDescriptor: BleServiceDescriptor
17 | ) {
18 | companion object {
19 | val log = LogTag.ble.forClass()
20 | }
21 | }
22 |
23 | internal suspend fun ApplePeripheralHardware(
24 | scope: CoroutineScope,
25 | serviceDescriptor: BleServiceDescriptor
26 | ): ApplePeripheralHardware {
27 | val managerDelegate = ApplePeripheralManagerDelegate(scope)
28 | val manager = CBPeripheralManager(managerDelegate, null)
29 | /* Wait for bluetooth to power on */
30 | managerDelegate.state.first { it == CBPeripheralManagerStatePoweredOn }
31 |
32 | val service = CBMutableService(serviceDescriptor)
33 | manager.addService(service)
34 | managerDelegate.didAddService.first { it.service == service }.error?.let { error ->
35 | log.error("Failed adding service $serviceDescriptor: ${error.localizedDescription}")
36 | }
37 |
38 | return ApplePeripheralHardware(
39 | manager = manager, delegate = managerDelegate, service = service, serviceDescriptor = serviceDescriptor
40 | )
41 | }
--------------------------------------------------------------------------------
/bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidBleImpl.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import android.content.Context
4 | import io.sellmair.pacemaker.ble.impl.BleCentralServiceImpl
5 | import io.sellmair.pacemaker.ble.impl.BlePeripheralServiceImpl
6 | import kotlinx.coroutines.*
7 | import java.io.Closeable
8 |
9 | suspend fun AndroidBle(context: Context): Ble {
10 | /* Await permissions to be granted and bluetooth to be turned on*/
11 | while (
12 | !context.isBluetoothPermissionGranted() || !context.isBluetoothEnabled()
13 | ) delay(250)
14 |
15 | return AndroidBleImpl(context)
16 | }
17 |
18 |
19 | private class AndroidBleImpl(private val context: Context) : Ble, Closeable {
20 |
21 | @OptIn(ExperimentalCoroutinesApi::class)
22 | override val coroutineScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob())
23 |
24 | private val queue = BleQueue(coroutineScope.coroutineContext.job)
25 |
26 | override suspend fun createCentralService(service: BleServiceDescriptor): BleCentralService {
27 | val hardware = AndroidCentralHardware(context)
28 | val controller: BleCentralController = AndroidCentralController(coroutineScope, hardware, service)
29 | return BleCentralServiceImpl(coroutineScope, queue, controller, service)
30 | }
31 |
32 | override suspend fun createPeripheralService(service: BleServiceDescriptor): BlePeripheralService {
33 | val hardware = AndroidPeripheralHardware(context, coroutineScope, service)
34 | val controller = AndroidPeripheralController(coroutineScope, hardware)
35 | return BlePeripheralServiceImpl(queue, controller, service, coroutineScope)
36 | }
37 |
38 | override fun close() {
39 | coroutineScope.cancel()
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/impl/BleConnectionImpl.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble.impl
2 |
3 | import io.sellmair.pacemaker.ble.*
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.flow.SharedFlow
6 | import kotlinx.coroutines.isActive
7 |
8 | internal class BleConnectionImpl(
9 | override val scope: CoroutineScope,
10 | private val queue: BleQueue,
11 | private val controller: BleConnectableController,
12 | override val service: BleServiceDescriptor
13 | ) : BleConnection {
14 | override val deviceId: BleDeviceId = controller.deviceId
15 |
16 | override val receivedValues: SharedFlow
17 | get() = controller.values
18 |
19 | override suspend fun enableNotifications(characteristic: BleCharacteristicDescriptor): BleResult {
20 | if (!scope.isActive) BleFailure.Rejected
21 | return queue enqueue EnableNotificationsBleOperation(controller.deviceId, characteristic) {
22 | controller.enableNotification(characteristic)
23 | }
24 | }
25 |
26 | override suspend fun requestRead(characteristic: BleCharacteristicDescriptor): BleResult {
27 | if (!scope.isActive) return BleFailure.Rejected
28 | return queue enqueue ReadCharacteristicBleOperation(controller.deviceId, characteristic) {
29 | controller.readValue(characteristic)
30 | }
31 | }
32 |
33 | override suspend fun setValue(
34 | characteristic: BleCharacteristicDescriptor, value: ByteArray
35 | ): BleResult {
36 | if (!scope.isActive) return BleFailure.Rejected
37 | return queue enqueue WriteCharacteristicBleOperation(controller.deviceId, characteristic) {
38 | controller.writeValue(characteristic, value)
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateSensorAutoConnector.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.ble.BleConnectable
4 | import io.sellmair.pacemaker.bluetooth.HeartRateSensor
5 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService
6 | import io.sellmair.pacemaker.bluetooth.toHeartRateSensorId
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Deferred
9 | import kotlinx.coroutines.Job
10 | import kotlinx.coroutines.launch
11 |
12 | internal fun CoroutineScope.launchHeartRateSensorAutoConnector(
13 | userService: UserService,
14 | heartRateSensorBluetoothService: Deferred
15 | ) = launch {
16 | heartRateSensorBluetoothService.await().newSensorsNearby.collect { sensor ->
17 | /*
18 | A sensor that is already connected at startup:
19 | Maybe the sensor is already used for another running app. We therefore can safely assume
20 | that such sensor can be linked ot our account!
21 | */
22 | if (sensor.connectionState.value == BleConnectable.ConnectionState.Connected) {
23 | userService.linkSensor(userService.me(), sensor.deviceId.toHeartRateSensorId())
24 | }
25 | launchHeartRateSensorAutoConnector(userService, sensor)
26 | }
27 | }
28 |
29 | private fun CoroutineScope.launchHeartRateSensorAutoConnector(userService: UserService, sensor: HeartRateSensor): Job = launch {
30 | val meId = userService.me().id
31 |
32 | userService.findUserFlow(sensor.deviceId.toHeartRateSensorId()).collect { userOrNull ->
33 | if (userOrNull != null && (userOrNull.id == meId || userOrNull.isAdhoc)) {
34 | sensor.connectIfPossible(true)
35 | } else {
36 | sensor.connectIfPossible(false)
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/BluetoothState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.State
4 | import io.sellmair.evas.launchState
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Job
7 | import kotlinx.coroutines.delay
8 | import kotlinx.coroutines.flow.combine
9 | import kotlinx.coroutines.flow.flow
10 | import kotlin.time.Duration.Companion.seconds
11 |
12 | data class BluetoothState(
13 | val isBluetoothEnabled: Boolean,
14 | val isBluetoothPermissionGranted: Boolean
15 | ) : State {
16 | companion object Key : State.Key {
17 | override val default: BluetoothState? = null
18 | }
19 | }
20 |
21 | internal fun CoroutineScope.launchBluetoothStateActor(): Job = launchState(BluetoothState) {
22 | val isBluetoothEnabledFlow = flow {
23 | while (true) {
24 | val isBluetoothEnabled = isBluetoothEnabled()
25 | emit(isBluetoothEnabled)
26 | delay(1.seconds)
27 | }
28 | }
29 |
30 | val isBluetoothPermissionGrantedFlow = flow {
31 | while (true) {
32 | val isBluetoothPermissionGranted = isBluetoothPermissionGranted()
33 | emit(isBluetoothPermissionGranted)
34 | /* Cannot be revoked w/o restarting the process. Once granted we can assume that this is it */
35 | if (isBluetoothPermissionGranted) break
36 | delay(0.5.seconds)
37 | }
38 | }
39 |
40 | this emitAll combine(isBluetoothEnabledFlow, isBluetoothPermissionGrantedFlow) { isBluetoothEnabled, isBluetoothPermissionGranted ->
41 | BluetoothState(isBluetoothEnabled, isBluetoothPermissionGranted)
42 | }
43 | }
44 |
45 | internal expect suspend fun isBluetoothEnabled(): Boolean
46 |
47 | internal expect suspend fun isBluetoothPermissionGranted(): Boolean
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/ExperimentalFeatureToggle.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.foundation.ExperimentalFoundationApi
4 | import androidx.compose.foundation.combinedClickable
5 | import androidx.compose.foundation.interaction.MutableInteractionSource
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.runtime.rememberCoroutineScope
9 | import androidx.compose.ui.Modifier
10 | import io.sellmair.evas.compose.eventsOrNull
11 | import io.sellmair.pacemaker.ApplicationFeature
12 | import kotlinx.coroutines.delay
13 | import kotlinx.coroutines.launch
14 | import kotlin.coroutines.EmptyCoroutineContext
15 | import kotlin.time.Duration.Companion.seconds
16 |
17 |
18 | @OptIn(ExperimentalFoundationApi::class)
19 | @Composable
20 | fun Modifier.experimentalFeatureToggle(): Modifier {
21 | val eventBus = eventsOrNull()
22 | val coroutineScope = rememberCoroutineScope { eventBus ?: EmptyCoroutineContext }
23 | val experimentalFeatures = ApplicationFeature.entries.filter { !it.default }
24 |
25 | var doubleClicked = false
26 |
27 | return combinedClickable(
28 | onLongClick = {
29 | if (!doubleClicked) return@combinedClickable
30 | coroutineScope.launch {
31 | experimentalFeatures.forEach { feature ->
32 | feature.toggle()
33 | }
34 | }
35 | },
36 | onDoubleClick = {
37 | doubleClicked = true
38 | coroutineScope.launch {
39 | delay(2.seconds)
40 | doubleClicked = false
41 | }
42 | },
43 | onClick = {},
44 | indication = null,
45 | interactionSource = remember { MutableInteractionSource() }
46 | )
47 | }
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/MemberHeartRateLimit.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.animation.core.Animatable
4 | import androidx.compose.foundation.Canvas
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.runtime.rememberCoroutineScope
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Brush
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.unit.dp
14 | import io.sellmair.pacemaker.UserState
15 | import io.sellmair.pacemaker.model.HeartRate
16 | import io.sellmair.pacemaker.displayColorLight
17 | import io.sellmair.pacemaker.ui.toColor
18 | import kotlinx.coroutines.launch
19 |
20 | @Composable
21 | internal fun MemberHeartRateLimit(
22 | userState: UserState,
23 | range: ClosedRange
24 | ) {
25 | val user = userState.user
26 | val heartRateLimit = userState.heartRateLimit ?: return
27 |
28 | val animatableHeartRateLimit = remember(user.id.value) { Animatable(heartRateLimit.value) }
29 | rememberCoroutineScope().launch {
30 | animatableHeartRateLimit.animateTo(
31 | heartRateLimit.value,
32 | animationSpec = onHeartRateScaleSpring()
33 | )
34 | }
35 |
36 | OnHeartRateScalePosition(HeartRate(animatableHeartRateLimit.value), range, side = ScaleSide.Left) {
37 | Canvas(
38 | modifier = Modifier
39 | .fillMaxWidth(.4f)
40 | .height(1.dp)
41 | ) {
42 | drawRect(
43 | Brush.linearGradient(
44 | listOf(Color.Transparent, userState.displayColorLight.toColor()),
45 | )
46 | )
47 | }
48 | }
49 | }
--------------------------------------------------------------------------------
/app-core/src/iosMain/kotlin/io/sellmair/pacemaker/IosApplicationBackend.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import app.cash.sqldelight.driver.native.NativeSqliteDriver
4 | import com.russhwolf.settings.NSUserDefaultsSettings
5 | import com.russhwolf.settings.Settings
6 | import io.sellmair.evas.*
7 | import io.sellmair.pacemaker.ble.AppleBle
8 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService
9 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService
10 | import io.sellmair.pacemaker.sql.PacemakerDatabase
11 | import kotlinx.coroutines.CoroutineScope
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.SupervisorJob
14 | import kotlinx.coroutines.async
15 | import platform.Foundation.NSUserDefaults
16 | import kotlin.coroutines.CoroutineContext
17 |
18 | class IosApplicationBackend : ApplicationBackend, CoroutineScope {
19 |
20 | override val coroutineContext: CoroutineContext = Dispatchers.Main + SupervisorJob() + Events() + States()
21 |
22 | override val events: Events get() = coroutineContext.eventsOrThrow
23 |
24 | override val states: States = coroutineContext.statesOrThrow
25 |
26 | private val ble = AppleBle()
27 |
28 | override val pacemakerBluetoothService = async { PacemakerBluetoothService(ble) }
29 |
30 | override val heartRateSensorBluetoothService = async { HeartRateSensorBluetoothService(ble) }
31 |
32 | override val settings: Settings = NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
33 |
34 | private val meId by lazy { settings.meId }
35 |
36 | private val pacemakerDatabase = SafePacemakerDatabase {
37 | PacemakerDatabase(NativeSqliteDriver(PacemakerDatabase.Schema, "app.db"))
38 | }
39 |
40 | override val userService: UserService by lazy {
41 | SqlUserService(pacemakerDatabase, meId)
42 | }
43 |
44 | override val sessionService: SessionService by lazy {
45 | SqlSessionService(pacemakerDatabase)
46 | }
47 |
48 |
49 | init {
50 | launchApplicationBackend(this)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/Ble.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.flow.SharedFlow
5 | import kotlinx.coroutines.flow.StateFlow
6 |
7 | interface Ble {
8 | val coroutineScope: CoroutineScope
9 | fun close()
10 | suspend fun createCentralService(service: BleServiceDescriptor): BleCentralService
11 | suspend fun createPeripheralService(service: BleServiceDescriptor): BlePeripheralService
12 | }
13 |
14 | interface BleWritable {
15 | suspend fun setValue(characteristic: BleCharacteristicDescriptor, value: ByteArray): BleUnit
16 | }
17 |
18 | class BleReceivedValue(
19 | val deviceId: BleDeviceId,
20 | val characteristic: BleCharacteristicDescriptor,
21 | val data: ByteArray
22 | )
23 |
24 | interface BleConnectable {
25 | enum class ConnectionState {
26 | Disconnected,
27 | Connecting,
28 | Connected
29 | }
30 |
31 | val deviceName: String?
32 | val deviceId: BleDeviceId
33 | val service: BleServiceDescriptor
34 |
35 | val connection: SharedFlow
36 | val connectionState: StateFlow
37 | val connectIfPossible: StateFlow
38 | fun connectIfPossible(connect: Boolean)
39 |
40 | val rssi: StateFlow
41 | }
42 |
43 | interface BleConnection : BleWritable {
44 | val deviceId: BleDeviceId
45 | val scope: CoroutineScope
46 | val service: BleServiceDescriptor
47 | val receivedValues: SharedFlow
48 |
49 |
50 | suspend fun enableNotifications(characteristic: BleCharacteristicDescriptor): BleUnit
51 | suspend fun requestRead(characteristic: BleCharacteristicDescriptor): BleResult
52 | }
53 |
54 | interface BlePeripheralService : BleWritable {
55 | val service: BleServiceDescriptor
56 | val receivedWrites: SharedFlow
57 |
58 | suspend fun startAdvertising()
59 | }
60 |
61 | interface BleCentralService {
62 | /**
63 | * Will replay all connectables on subscribe!
64 | */
65 | val connectables: SharedFlow
66 | fun startScanning()
67 | }
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import com.russhwolf.settings.Settings
4 | import io.sellmair.evas.Events
5 | import io.sellmair.evas.States
6 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService
7 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService
8 | import kotlinx.coroutines.CoroutineScope
9 | import kotlinx.coroutines.Deferred
10 |
11 | interface ApplicationBackend {
12 | val pacemakerBluetoothService: Deferred
13 | val heartRateSensorBluetoothService: Deferred
14 | val sessionService: SessionService
15 | val userService: UserService
16 | val states: States
17 | val events: Events
18 | val settings: Settings
19 | }
20 |
21 | fun ApplicationBackend.launchApplicationBackend(scope: CoroutineScope) {
22 | scope.launchGroupStateActor(userService)
23 | scope.launchMeStateActor(userService)
24 | scope.launchPacemakerBroadcastSender(pacemakerBluetoothService)
25 | scope.launchPacemakerBroadcastReceiver(userService, pacemakerBluetoothService)
26 | scope.launchHeartRateSensorMeasurement(heartRateSensorBluetoothService)
27 | scope.launchHeartRateSensorAutoConnector(userService, heartRateSensorBluetoothService)
28 | scope.launchCriticalGroupStateActor()
29 | scope.launchSessionActor(userService, sessionService)
30 | scope.launchHeartRateUtteranceProducer()
31 | scope.launchUtteranceSettingsActor(settings)
32 | scope.launchApplicationFeatureActor(settings)
33 | scope.launchAdhocUserActor(userService)
34 | scope.launchHeartRateSensorLinkingActor(userService)
35 | scope.launchUpdateMeActor(userService)
36 | scope.launchHeartRateSensorsStateActor(heartRateSensorBluetoothService)
37 | scope.launchHeartRateSensorStateActor(userService, heartRateSensorBluetoothService)
38 | scope.launchHeartRateSensorConnectionStateActor(userService, heartRateSensorBluetoothService)
39 | scope.launchSessionStateActor(sessionService)
40 | scope.launchMeColorStateActor(settings)
41 | scope.launchBluetoothStateActor()
42 | launchPlatform(scope)
43 | }
44 |
45 | expect fun ApplicationBackend.launchPlatform(scope: CoroutineScope)
46 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
15 |
18 |
19 |
20 |
22 |
23 |
25 |
28 |
29 |
31 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/impl/BleCentralServiceImpl.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble.impl
2 |
3 | import io.sellmair.pacemaker.ble.BleCentralController
4 | import io.sellmair.pacemaker.ble.BleCentralService
5 | import io.sellmair.pacemaker.ble.BleConnectable
6 | import io.sellmair.pacemaker.ble.BleDeviceId
7 | import io.sellmair.pacemaker.ble.BleQueue
8 | import io.sellmair.pacemaker.ble.BleServiceDescriptor
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.channels.Channel
11 | import kotlinx.coroutines.flow.MutableSharedFlow
12 | import kotlinx.coroutines.flow.consumeAsFlow
13 | import kotlinx.coroutines.launch
14 |
15 | internal class BleCentralServiceImpl(
16 | scope: CoroutineScope,
17 | private val queue: BleQueue,
18 | private val controller: BleCentralController,
19 | private val service: BleServiceDescriptor
20 | ) : BleCentralService {
21 |
22 | private val connectableById = mutableMapOf()
23 |
24 | override val connectables: MutableSharedFlow = MutableSharedFlow(replay = Channel.UNLIMITED)
25 |
26 | override fun startScanning() {
27 | controller.startScanning()
28 | }
29 |
30 | init {
31 | scope.launch {
32 | controller.scanResults.consumeAsFlow().collect { result ->
33 | connectableById.getOrPut(result.deviceId) {
34 | BleConnectableImpl(
35 | scope, queue, controller.createConnectableController(result), service
36 | ).also { newConnectable ->
37 | connectables.emit(newConnectable)
38 | }
39 | }.onScanResult(result)
40 | }
41 | }
42 |
43 | scope.launch {
44 | controller.connectedDevices.consumeAsFlow().collect { connectedDevice ->
45 | connectableById.getOrPut(connectedDevice.deviceId) {
46 | BleConnectableImpl(
47 | scope, queue, connectedDevice, service
48 | ).also { newConnectable ->
49 | connectables.emit(newConnectable)
50 | }
51 | }
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/UserHead.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.foundation.Canvas
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.size
7 | import androidx.compose.foundation.layout.wrapContentHeight
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.graphics.SolidColor
14 | import androidx.compose.ui.text.TextStyle
15 | import androidx.compose.ui.text.style.TextAlign
16 | import androidx.compose.ui.unit.Dp
17 | import androidx.compose.ui.unit.dp
18 | import androidx.compose.ui.unit.sp
19 | import io.sellmair.pacemaker.UserState
20 | import io.sellmair.pacemaker.displayColor
21 | import io.sellmair.pacemaker.model.nameAbbreviation
22 | import io.sellmair.pacemaker.ui.toColor
23 |
24 | @Composable
25 | internal fun UserHead(
26 | userState: UserState,
27 | modifier: Modifier = Modifier,
28 | size: Dp = 24.dp,
29 | ) {
30 | UserHead(
31 | abbreviation = userState.user.nameAbbreviation,
32 | color = userState.displayColor.toColor(),
33 | modifier = modifier,
34 | size = size
35 | )
36 | }
37 |
38 | @Composable
39 | internal fun UserHead(
40 | abbreviation: String,
41 | color: Color,
42 | modifier: Modifier = Modifier,
43 | size: Dp = 24.dp,
44 | ) {
45 | Box(modifier.size(size), contentAlignment = Alignment.Center) {
46 | Canvas(modifier = Modifier.fillMaxSize()) {
47 | drawCircle(SolidColor(color))
48 | }
49 | Text(
50 | text = abbreviation,
51 | color = Color.White,
52 | fontSize = 10.sp,
53 | textAlign = TextAlign.Center,
54 | style = TextStyle.Default,
55 | modifier = Modifier
56 | .fillMaxSize()
57 | .wrapContentHeight(
58 | align = Alignment.CenterVertically, // Default value
59 | unbounded = true // Makes sense if the size less than text
60 | )
61 | )
62 | }
63 | }
--------------------------------------------------------------------------------
/bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerServiceDescriptors.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.bluetooth
2 |
3 | import io.sellmair.pacemaker.ble.BleCharacteristicDescriptor
4 | import io.sellmair.pacemaker.ble.BleServiceDescriptor
5 | import io.sellmair.pacemaker.ble.BleUUID
6 |
7 | internal object PacemakerServiceDescriptors {
8 | val userIdCharacteristic = BleCharacteristicDescriptor(
9 | name = "userId",
10 | uuid = BleUUID(PacemakerServiceConstants.userIdCharacteristicUuidString),
11 | isReadable = true,
12 | isWritable = true,
13 | isNotificationsEnabled = false
14 | )
15 |
16 | val userNameCharacteristic = BleCharacteristicDescriptor(
17 | name = "userName",
18 | uuid = BleUUID(PacemakerServiceConstants.userNameCharacteristicUuidString),
19 | isReadable = true,
20 | isWritable = true,
21 | isNotificationsEnabled = false
22 | )
23 |
24 | val userColorHueCharacteristic = BleCharacteristicDescriptor(
25 | name = "userColorHue",
26 | uuid = BleUUID(PacemakerServiceConstants.userColorHueCharacteristcUuidString),
27 | isReadable = true,
28 | isWritable = true,
29 | isNotificationsEnabled = true
30 | )
31 |
32 | val heartRateCharacteristic = BleCharacteristicDescriptor(
33 | name = "heartRate",
34 | uuid = BleUUID(PacemakerServiceConstants.heartRateCharacteristicUuidString),
35 | isReadable = true,
36 | isWritable = true,
37 | isNotificationsEnabled = true
38 | )
39 |
40 | val heartRateLimitCharacteristic = BleCharacteristicDescriptor(
41 | name = "heartRateLimit",
42 | uuid = BleUUID(PacemakerServiceConstants.heartRateLimitCharacteristicUuidString),
43 | isReadable = true,
44 | isWritable = true,
45 | isNotificationsEnabled = true
46 | )
47 |
48 | val service = BleServiceDescriptor(
49 | name = "Pacemaker App",
50 | uuid = BleUUID(PacemakerServiceConstants.serviceUuidString),
51 | characteristics = setOf(
52 | userIdCharacteristic,
53 | userNameCharacteristic,
54 | userColorHueCharacteristic,
55 | heartRateCharacteristic,
56 | heartRateLimitCharacteristic
57 | )
58 | )
59 | }
60 |
61 |
--------------------------------------------------------------------------------
/app-core/src/androidMain/kotlin/io/sellmair/pacemaker/launchTextToSpeech.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import android.content.Context
4 | import android.media.AudioAttributes
5 | import android.media.AudioFocusRequest
6 | import android.media.AudioManager
7 | import android.speech.tts.TextToSpeech
8 | import androidx.core.content.getSystemService
9 | import io.sellmair.evas.collectEvents
10 | import kotlinx.coroutines.CoroutineScope
11 | import kotlinx.coroutines.delay
12 | import kotlinx.coroutines.launch
13 | import kotlin.coroutines.resume
14 | import kotlin.coroutines.suspendCoroutine
15 |
16 | internal fun CoroutineScope.launchTextToSpeech(context: Context) = launch {
17 | val textToSpeech = TextToSpeech(context) ?: return@launch
18 | textToSpeech.setSpeechRate(1.3f)
19 | textToSpeech.setPitch(0.85f)
20 |
21 | collectEvents { event ->
22 | if (!UtteranceState.shouldBeAnnounced(event)) return@collectEvents
23 | if (textToSpeech.isSpeaking) return@collectEvents
24 |
25 | val audioAttributes = AudioAttributes.Builder()
26 | .setUsage(AudioAttributes.USAGE_ASSISTANT)
27 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
28 | .build()
29 | textToSpeech.setAudioAttributes(audioAttributes)
30 |
31 | val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
32 | .setAudioAttributes(audioAttributes)
33 | .setAcceptsDelayedFocusGain(false)
34 | .setWillPauseWhenDucked(false)
35 | .build()
36 |
37 | val audioManager = context.getSystemService()
38 |
39 | audioManager?.requestAudioFocus(focusRequest)
40 | textToSpeech.speak(event.message, TextToSpeech.QUEUE_FLUSH, null)
41 |
42 | do {
43 | delay(100)
44 | } while (textToSpeech.isSpeaking)
45 |
46 | audioManager?.abandonAudioFocusRequest(focusRequest)
47 | }
48 | }
49 |
50 |
51 | private suspend fun TextToSpeech(context: Context): TextToSpeech? {
52 | return suspendCoroutine { continuation ->
53 | lateinit var textToSpeech: TextToSpeech
54 | textToSpeech = TextToSpeech(context) { status ->
55 | if (status == TextToSpeech.SUCCESS) {
56 | continuation.resume(textToSpeech)
57 | } else continuation.resume(null)
58 | }
59 | }
60 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:SuppressLint("TestManifestGradleConfiguration")
2 | @file:OptIn(ExperimentalKotlinGradlePluginApi::class)
3 |
4 | import android.annotation.SuppressLint
5 | import com.android.build.api.dsl.ApplicationExtension
6 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
7 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
8 |
9 | plugins {
10 | `pacemaker-application`
11 | org.jetbrains.compose
12 | org.jetbrains.kotlin.plugin.compose
13 | id("org.jetbrains.compose.hot-reload")
14 | }
15 |
16 | pacemaker {
17 | ios()
18 | android()
19 | jvm()
20 | }
21 |
22 | extensions.configure(ApplicationExtension::class) {
23 | namespace = "io.sellmair.pacemaker"
24 |
25 | defaultConfig {
26 | versionName = "2025.1"
27 | versionCode = 17
28 | }
29 | }
30 |
31 | kotlin {
32 | compilerOptions {
33 | optIn.add("org.jetbrains.compose.resources.ExperimentalResourceApi")
34 | }
35 |
36 | sourceSets.commonMain.get().dependencies {
37 | implementation(project(":app-core"))
38 |
39 | implementation(deps.evas.compose)
40 |
41 | /* COMPOSE */
42 | implementation(compose.ui)
43 | implementation(compose.foundation)
44 | implementation(compose.runtime)
45 | implementation(compose.components.resources)
46 | implementation(compose.components.uiToolingPreview)
47 |
48 | implementation(compose.material3)
49 | implementation(compose.materialIconsExtended)
50 |
51 | }
52 |
53 | sourceSets.jvmMain.dependencies {
54 | implementation(compose.desktop.currentOs)
55 | implementation(deps.coroutines.swing)
56 | }
57 |
58 | sourceSets.androidMain.get().dependencies {
59 | /* androidx */
60 | implementation("androidx.activity:activity-compose:1.9.3")
61 | implementation(compose.preview)
62 | }
63 |
64 | @OptIn(ExperimentalKotlinGradlePluginApi::class)
65 | sourceSets.invokeWhenCreated("androidDebug") {
66 | dependencies {
67 | implementation(compose.uiTooling)
68 | }
69 | }
70 |
71 | sourceSets.getByName("androidInstrumentedTest").dependencies {
72 | implementation("androidx.compose.ui:ui-test-junit4:1.7.6")
73 | }
74 | }
75 |
76 | kotlin.targets.withType().configureEach {
77 | binaries.framework {
78 | baseName = "LibPacemaker"
79 | isStatic = true
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchAdhocUserActor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.pacemaker.model.HeartRateSensorId
5 | import io.sellmair.pacemaker.model.User
6 | import io.sellmair.pacemaker.model.randomUserId
7 | import io.sellmair.evas.Event
8 | import io.sellmair.evas.collectEvents
9 | import io.sellmair.evas.collectEventsAsync
10 | import io.sellmair.evas.Events
11 | import kotlinx.coroutines.CoroutineScope
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.Job
14 | import kotlinx.coroutines.launch
15 | import kotlin.math.absoluteValue
16 |
17 | sealed class AdhocUserIntent : Event {
18 | data class CreateAdhocUser(val sensor: HeartRateSensorId) : AdhocUserIntent()
19 | data class UpdateAdhocUser(val user: User) : AdhocUserIntent()
20 | data class DeleteAdhocUser(val user: User) : AdhocUserIntent()
21 | data class UpdateAdhocUserLimit(val user: User, val limit: HeartRate) : AdhocUserIntent()
22 | }
23 |
24 | internal fun CoroutineScope.launchAdhocUserActor(userService: UserService): Job = launch(Dispatchers.Main.immediate) {
25 | suspend fun createAdhocUser(intent: AdhocUserIntent.CreateAdhocUser) {
26 | val id = randomUserId()
27 | val adhocUser = User(
28 | id = id,
29 | name = "Adhoc ${id.value.absoluteValue % 1000}",
30 | isAdhoc = true
31 | )
32 | userService.saveUser(adhocUser)
33 | userService.linkSensor(adhocUser, intent.sensor)
34 | userService.saveHeartRateLimit(adhocUser, HeartRate(130))
35 | }
36 |
37 | suspend fun updateAdhocUser(intent: AdhocUserIntent.UpdateAdhocUser) {
38 | userService.saveUser(intent.user)
39 | }
40 |
41 | suspend fun deleteAdhocUser(intent: AdhocUserIntent.DeleteAdhocUser) {
42 | userService.deleteUser(intent.user)
43 | }
44 |
45 | suspend fun updateAdhocUserLimit(intent: AdhocUserIntent.UpdateAdhocUserLimit) {
46 | userService.saveHeartRateLimit(intent.user, intent.limit)
47 | }
48 |
49 | collectEvents { intent ->
50 | when (intent) {
51 | is AdhocUserIntent.CreateAdhocUser -> createAdhocUser(intent)
52 | is AdhocUserIntent.DeleteAdhocUser -> deleteAdhocUser(intent)
53 | is AdhocUserIntent.UpdateAdhocUser -> updateAdhocUser(intent)
54 | is AdhocUserIntent.UpdateAdhocUserLimit -> updateAdhocUserLimit(intent)
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/ApplicationFeature.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import com.russhwolf.settings.Settings
4 | import com.russhwolf.settings.set
5 | import io.sellmair.evas.*
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlin.time.Duration.Companion.INFINITE
9 |
10 | enum class ApplicationFeature(val default: Boolean) {
11 | /**
12 | * Allow to record and then view sessions
13 | */
14 | Sessions(false);
15 |
16 | val state get() = ApplicationFeatureState.Key(this)
17 |
18 | suspend fun enable() {
19 | ApplicationFeatureEvent.Enable(this).emit()
20 | }
21 |
22 | suspend fun disable() {
23 | ApplicationFeatureEvent.Disable(this).emit()
24 | }
25 |
26 | suspend fun toggle() {
27 | ApplicationFeatureEvent.Toggle(this).emit()
28 | }
29 | }
30 |
31 | data class ApplicationFeatureState(
32 | val feature: ApplicationFeature,
33 | val enabled: Boolean,
34 | ) : State {
35 | data class Key(val feature: ApplicationFeature) : State.Key {
36 | override val default: ApplicationFeatureState = when (feature) {
37 | ApplicationFeature.Sessions -> ApplicationFeatureState(feature, feature.default)
38 | }
39 | }
40 | }
41 |
42 | sealed class ApplicationFeatureEvent : Event {
43 | data class Enable(val feature: ApplicationFeature) : ApplicationFeatureEvent()
44 | data class Disable(val feature: ApplicationFeature) : ApplicationFeatureEvent()
45 | data class Toggle(val feature: ApplicationFeature) : ApplicationFeatureEvent()
46 | }
47 |
48 | internal fun CoroutineScope.launchApplicationFeatureActor(settings: Settings) = launchState(
49 | coroutineContext = Dispatchers.Main.immediate,
50 | keepActive = INFINITE
51 | ) { key: ApplicationFeatureState.Key ->
52 | val settingsKey = "${ApplicationFeature::class.qualifiedName}:${key.feature.name}"
53 | var enabled = settings.getBooleanOrNull(settingsKey) ?: key.default.enabled
54 | ApplicationFeatureState(key.feature, enabled).emit()
55 |
56 | collectEvents { event ->
57 | enabled = when (event) {
58 | is ApplicationFeatureEvent.Disable -> false
59 | is ApplicationFeatureEvent.Enable -> true
60 | is ApplicationFeatureEvent.Toggle -> !enabled
61 | }
62 | ApplicationFeatureState(key.feature, enabled).emit()
63 | settings[settingsKey] = enabled
64 | }
65 | }
66 |
67 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateUtteranceProducer.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.pacemaker.model.HeartRate
4 | import io.sellmair.evas.Event
5 | import io.sellmair.evas.collect
6 | import io.sellmair.evas.emit
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.delay
9 | import kotlinx.coroutines.launch
10 | import kotlin.math.roundToInt
11 | import kotlin.time.Duration.Companion.minutes
12 | import kotlin.time.Duration.Companion.seconds
13 |
14 |
15 | sealed class HeartRateUtteranceRequest : Event {
16 | /**
17 | * At least one person is in 'critical state', exceeding his limit
18 | */
19 | class SlowDownHeartRateUtterance(val criticalStates: List) : HeartRateUtteranceRequest()
20 |
21 | /**
22 | * Just a status update
23 | */
24 | class InfoHeartRateUtterance(val myHeartRate: HeartRate, val myHeartRateLimit: HeartRate) : HeartRateUtteranceRequest()
25 | }
26 |
27 | internal fun CoroutineScope.launchHeartRateUtteranceProducer() = launch {
28 |
29 | var group: GroupState? = null
30 | var criticalUserStates = listOf()
31 |
32 |
33 | /* Text To Speech: Tell user who is over the limit */
34 | launch {
35 | while (true) {
36 | delay(15.seconds)
37 | val criticalStates = criticalUserStates.toList()
38 | if (criticalStates.isNotEmpty()) {
39 | HeartRateUtteranceRequest.SlowDownHeartRateUtterance(criticalStates).emit()
40 | }
41 | }
42 | }
43 |
44 | /* Text To Speech: Tell heart rate every minute */
45 | launch {
46 | while (true) {
47 | delay(1.minutes)
48 | val me = group?.members.orEmpty().firstOrNull { it.isMe }
49 | val heartRate = me?.heartRate?.value?.roundToInt() ?: continue
50 | val limit = me.heartRateLimit?.value?.roundToInt() ?: continue
51 |
52 | HeartRateUtteranceRequest.InfoHeartRateUtterance(
53 | myHeartRate = HeartRate(heartRate),
54 | myHeartRateLimit = HeartRate(limit)
55 | ).emit()
56 | }
57 | }
58 |
59 | /* Collect critical member states */
60 | GroupState.collect { state ->
61 | group = state
62 | criticalUserStates = state.members.filter { memberState ->
63 | val currentHeartRate = memberState.heartRate
64 | val currentHeartRateLimit = memberState.heartRateLimit ?: return@filter false
65 | currentHeartRate > currentHeartRateLimit
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/launchHeartRateUtteranceActor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import io.sellmair.pacemaker.HeartRateUtteranceRequest
4 | import io.sellmair.pacemaker.UtteranceEvent
5 | import io.sellmair.evas.collectEventsAsync
6 | import io.sellmair.evas.emit
7 | import kotlinx.coroutines.CoroutineScope
8 | import org.jetbrains.compose.resources.getString
9 | import pacemaker.app.generated.resources.*
10 | import pacemaker.app.generated.resources.Res
11 | import pacemaker.app.generated.resources.heart_rate_info_utterance
12 | import pacemaker.app.generated.resources.slow_down_heart_rate_utterance_fragment_me
13 | import pacemaker.app.generated.resources.slow_down_heart_rate_utterance_fragment_other
14 | import kotlin.math.roundToInt
15 |
16 | fun CoroutineScope.launchHeartRateUtteranceActor() = collectEventsAsync { request ->
17 | request.toUtterance().emit()
18 | }
19 |
20 | private suspend fun HeartRateUtteranceRequest.toUtterance() = UtteranceEvent(
21 | type = when (this) {
22 | is HeartRateUtteranceRequest.InfoHeartRateUtterance -> UtteranceEvent.Type.Info
23 | is HeartRateUtteranceRequest.SlowDownHeartRateUtterance -> UtteranceEvent.Type.Warning
24 | },
25 | message = this.createMessage()
26 | )
27 |
28 | private suspend fun HeartRateUtteranceRequest.createMessage(): String = when (this) {
29 | is HeartRateUtteranceRequest.InfoHeartRateUtterance -> createInfoHeartRateUtteranceMessage()
30 | is HeartRateUtteranceRequest.SlowDownHeartRateUtterance -> createSlowDownHeartRateUtteranceMessage()
31 | }
32 |
33 | private suspend fun HeartRateUtteranceRequest.InfoHeartRateUtterance.createInfoHeartRateUtteranceMessage(): String {
34 | return getString(
35 | Res.string.heart_rate_info_utterance,
36 | myHeartRate.value.roundToInt(),
37 | myHeartRateLimit.value.roundToInt()
38 | )
39 | }
40 |
41 | private suspend fun HeartRateUtteranceRequest.SlowDownHeartRateUtterance.createSlowDownHeartRateUtteranceMessage(): String {
42 | return getString(Res.string.slow_down) + " " + criticalStates
43 | .sortedBy { it.isMe }
44 | .map { state ->
45 | if (state.isMe) getString(
46 | Res.string.slow_down_heart_rate_utterance_fragment_me,
47 | state.heartRate.value.roundToInt()
48 | )
49 | else getString(
50 | Res.string.slow_down_heart_rate_utterance_fragment_other,
51 | state.user.name,
52 | state.heartRate.value.roundToInt()
53 | )
54 | }.joinToString(". ")
55 | }
56 |
--------------------------------------------------------------------------------
/app-core/src/jvmMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.jvm.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
4 | import com.russhwolf.settings.PreferencesSettings
5 | import com.russhwolf.settings.Settings
6 | import io.sellmair.evas.Events
7 | import io.sellmair.evas.States
8 | import io.sellmair.pacemaker.bluetooth.HeartRateSensor
9 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService
10 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService
11 | import io.sellmair.pacemaker.sql.PacemakerDatabase
12 | import kotlinx.coroutines.CompletableDeferred
13 | import kotlinx.coroutines.CoroutineScope
14 | import kotlinx.coroutines.Deferred
15 | import kotlinx.coroutines.flow.MutableSharedFlow
16 | import kotlinx.coroutines.flow.MutableStateFlow
17 | import kotlinx.coroutines.flow.update
18 | import java.util.prefs.Preferences
19 |
20 | actual fun ApplicationBackend.launchPlatform(scope: CoroutineScope) {
21 | scope.launchHeartRateSensorEmulation()
22 | }
23 |
24 | object JvmApplicationBackend : ApplicationBackend {
25 | override val pacemakerBluetoothService: Deferred
26 | get() = CompletableDeferred()
27 |
28 | override val heartRateSensorBluetoothService: Deferred
29 | get() = CompletableDeferred(JvmHeartRateSensorBluetoothService)
30 |
31 | private val database by lazy { createInMemoryDatabase() }
32 |
33 | override val sessionService: SessionService by lazy {
34 | SqlSessionService(database)
35 | }
36 |
37 | private val meId by lazy {
38 | settings.meId
39 | }
40 |
41 | override val userService: UserService by lazy {
42 | SqlUserService(database, meId)
43 | }
44 |
45 | override val states: States = States()
46 | override val events: Events = Events()
47 | override val settings: Settings = PreferencesSettings(Preferences.userRoot())
48 | }
49 |
50 |
51 | internal fun createInMemoryDatabase(): SafePacemakerDatabase = SafePacemakerDatabase {
52 | val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
53 | PacemakerDatabase.Schema.create(driver)
54 | PacemakerDatabase(driver)
55 | }
56 |
57 | object JvmHeartRateSensorBluetoothService : HeartRateSensorBluetoothService {
58 | fun addSensor(sensor: HeartRateSensor) {
59 | allSensorsNearby.update { sensors -> sensors + sensor }
60 | newSensorsNearby.tryEmit(sensor)
61 | }
62 |
63 | override val newSensorsNearby = MutableSharedFlow(replay = 1)
64 |
65 |
66 | override val allSensorsNearby = MutableStateFlow>(emptyList())
67 | }
68 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/settingsPage/SettingsPageHeader.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.settingsPage
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.text.BasicTextField
8 | import androidx.compose.foundation.text.KeyboardActions
9 | import androidx.compose.foundation.text.KeyboardOptions
10 | import androidx.compose.runtime.*
11 | import androidx.compose.ui.Alignment
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.platform.LocalFocusManager
14 | import androidx.compose.ui.text.TextStyle
15 | import androidx.compose.ui.text.input.ImeAction
16 | import androidx.compose.ui.unit.dp
17 | import io.sellmair.pacemaker.UpdateMeIntent
18 | import io.sellmair.pacemaker.model.User
19 | import io.sellmair.pacemaker.model.nameAbbreviation
20 | import io.sellmair.pacemaker.ui.MeColor
21 | import io.sellmair.pacemaker.ui.widget.*
22 | import io.sellmair.evas.emit
23 |
24 | @Composable
25 | internal fun SettingsPageHeader(me: User) {
26 | var userName by remember { mutableStateOf(me.name) }
27 |
28 | Column(modifier = Modifier.padding(24.dp)) {
29 | Row(Modifier.fillMaxWidth()) {
30 | val focusManager = LocalFocusManager.current
31 |
32 | BasicTextField(
33 | modifier = Modifier
34 | .weight(1f)
35 | .align(Alignment.CenterVertically),
36 | value = userName,
37 | textStyle = TextStyle.Headline,
38 | singleLine = true,
39 | keyboardActions = KeyboardActions(onDone = {
40 | this.defaultKeyboardAction(ImeAction.Done)
41 | focusManager.clearFocus()
42 | }),
43 | keyboardOptions = KeyboardOptions(
44 | autoCorrect = false,
45 | imeAction = ImeAction.Done
46 | ),
47 | onValueChange = Launching { newName ->
48 | userName = newName
49 | UpdateMeIntent.UpdateMe(me.copy(name = newName)).emit()
50 | })
51 |
52 | UserHead(
53 | abbreviation = me.nameAbbreviation,
54 | color = MeColor(),
55 | size = 32.dp,
56 | modifier = Modifier
57 | .padding(4.dp)
58 | .experimentalFeatureToggle()
59 | )
60 | }
61 |
62 | ColorHueSlider(
63 | modifier = Modifier.fillMaxWidth()
64 | )
65 |
66 |
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/MeState.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.State
4 | import io.sellmair.evas.collectEventsAsync
5 | import io.sellmair.evas.launchState
6 | import io.sellmair.pacemaker.bluetooth.HeartRateMeasurementEvent
7 | import io.sellmair.pacemaker.model.HeartRate
8 | import io.sellmair.pacemaker.model.User
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.channels.Channel
11 | import kotlinx.coroutines.channels.consumeEach
12 | import kotlinx.coroutines.flow.conflate
13 | import kotlinx.coroutines.launch
14 |
15 | data class MeState(
16 | val me: User,
17 | val heartRate: HeartRate?,
18 | val heartRateLimit: HeartRate
19 | ) : State {
20 | companion object Key : State.Key {
21 | override val default: MeState? = null
22 | }
23 | }
24 |
25 | internal fun CoroutineScope.launchMeStateActor(userService: UserService) {
26 |
27 | class Values(
28 | val me: User? = null,
29 | val heartRate: HeartRate? = null,
30 | val heartRateLimit: HeartRate? = null
31 | )
32 |
33 | val valuesChannel = Channel()
34 |
35 | /* Collect measurements */
36 | collectEventsAsync { measurement ->
37 | val user = userService.findUser(measurement.sensorId)
38 | val me = userService.me()
39 | if (user?.id == me.id) {
40 | valuesChannel.send(Values(me = me, heartRate = measurement.heartRate))
41 | }
42 | }
43 |
44 |
45 | /* Collect changes to HR limit */
46 | launch {
47 | userService.findHeartRateLimitFlow(userService.me()).collect { heartRateLimit ->
48 | valuesChannel.send(Values(heartRateLimit = heartRateLimit))
49 | }
50 | }
51 |
52 | /* Collect changes in user service (maybe 'me' was updated?) */
53 | launch {
54 | userService.onSaveUser.conflate().collect {
55 | valuesChannel.send(Values(me = userService.me()))
56 | }
57 | }
58 |
59 |
60 | /* Update MeState */
61 | launchState(MeState) {
62 | var values = Values(me = userService.me())
63 |
64 | valuesChannel.consumeEach { update ->
65 | values = Values(
66 | me = update.me ?: values.me,
67 | heartRate = update.heartRate ?: values.heartRate,
68 | heartRateLimit = update.heartRateLimit ?: values.heartRateLimit
69 | )
70 |
71 | MeState(
72 | me = values.me ?: return@consumeEach,
73 | heartRate = values.heartRate,
74 | heartRateLimit = values.heartRateLimit ?: return@consumeEach,
75 | ).emit()
76 | }
77 | }
78 | }
79 |
80 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/PacemakerTheme.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.material3.lightColorScheme
5 | import androidx.compose.runtime.*
6 | import androidx.compose.ui.graphics.Color
7 | import io.sellmair.evas.compose.composeFlow
8 | import io.sellmair.evas.flow
9 | import io.sellmair.pacemaker.MeColorState
10 | import io.sellmair.pacemaker.UserColors
11 | import kotlinx.coroutines.flow.distinctUntilChanged
12 | import kotlinx.coroutines.flow.filterNotNull
13 | import kotlinx.coroutines.flow.map
14 |
15 | interface PacemakerTheme {
16 |
17 |
18 | val meColor: Color
19 | val meColorLight: Color
20 | val meColorWhite: Color
21 | }
22 |
23 | private val PacemakerThemeCompositionLocal = compositionLocalOf { MissingPacemakerTheme }
24 |
25 | @Composable
26 | fun PacemakerTheme(): PacemakerTheme = PacemakerThemeCompositionLocal.current
27 |
28 | @Composable
29 | fun MeColor() = PacemakerTheme().meColor
30 |
31 | @Composable
32 | fun MeColorLight() = PacemakerTheme().meColorLight
33 |
34 | @Composable
35 | fun MeColorWhite() = PacemakerTheme().meColorWhite
36 |
37 | @Composable
38 | fun PacemakerTheme(content: @Composable () -> Unit) {
39 | val pacemakerTheme by MeColorState.composeFlow().filterNotNull()
40 | .map { state ->
41 | PacemakerThemeImpl(
42 | meColor = state.color.toColor(),
43 | meColorLight = UserColors.fromHueLight(state.color.hue).toColor(),
44 | meColorWhite = state.color.copy(lightness = .95f).toColor()
45 | )
46 | }
47 | .distinctUntilChanged()
48 | .collectAsState(MissingPacemakerTheme)
49 |
50 | val meColor = pacemakerTheme.meColor
51 |
52 |
53 | CompositionLocalProvider(
54 | PacemakerThemeCompositionLocal provides pacemakerTheme
55 | ) {
56 | MaterialTheme(
57 | colorScheme = lightColorScheme(
58 | primary = meColor,
59 | primaryContainer = meColor,
60 | secondaryContainer = meColor,
61 | onSecondaryContainer = Color.White,
62 | onPrimaryContainer = meColor,
63 | onTertiaryContainer = meColor,
64 | onSurface = meColor,
65 | onSurfaceVariant = meColor,
66 | )
67 | ) {
68 | content()
69 | }
70 | }
71 | }
72 |
73 | private data class PacemakerThemeImpl(
74 | override val meColor: Color,
75 | override val meColorLight: Color,
76 | override val meColorWhite: Color
77 | ) : PacemakerTheme
78 |
79 |
80 | private object MissingPacemakerTheme : PacemakerTheme {
81 | override val meColor: Color = Color.Gray
82 | override val meColorLight: Color = Color.LightGray
83 | override val meColorWhite: Color = Color.White
84 | }
85 |
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleCentralController.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ble
2 |
3 | import io.sellmair.pacemaker.ble.AppleCentralManagerDelegate.DidDiscoverPeripheral
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.channels.Channel
6 | import kotlinx.coroutines.launch
7 | import platform.CoreBluetooth.CBAdvertisementDataIsConnectable
8 | import platform.CoreBluetooth.CBPeripheral
9 |
10 | internal class AppleCentralController(
11 | private val scope: CoroutineScope,
12 | private val hardware: AppleCentralHardware
13 | ) : BleCentralController {
14 |
15 | override fun startScanning() {
16 | /* Search for already connected devices and emit them */
17 | val connectedPeripherals = hardware.manager.retrieveConnectedPeripheralsWithServices(listOf(hardware.serviceDescriptor.uuid))
18 | scope.launch {
19 | @Suppress("UNCHECKED_CAST")
20 | connectedPeripherals as List
21 | connectedPeripherals.forEach { peripheral ->
22 | val delegate = ApplePeripheralDelegate(scope)
23 | peripheral.delegate = delegate
24 | val controller = AppleConnectableController(
25 | scope, hardware, AppleConnectableHardware(peripheral, delegate, hardware.serviceDescriptor)
26 | )
27 | controller.connect()
28 | connectedDevices.send(controller)
29 | }
30 | }
31 |
32 | hardware.manager.scanForPeripheralsWithServices(
33 | listOf(hardware.serviceDescriptor.uuid),
34 | mutableMapOf()
35 | )
36 | }
37 |
38 | override val scanResults = Channel()
39 |
40 | override val connectedDevices = Channel()
41 |
42 | override fun createConnectableController(result: BleCentralController.ScanResult): BleConnectableController {
43 | result as MyScanResult
44 | val delegate = ApplePeripheralDelegate(scope)
45 | result.event.peripheral.delegate = delegate
46 |
47 | return AppleConnectableController(
48 | scope, hardware, AppleConnectableHardware(result.event.peripheral, delegate, hardware.serviceDescriptor)
49 | )
50 | }
51 |
52 | private class MyScanResult(val event: DidDiscoverPeripheral) : BleCentralController.ScanResult {
53 | override val deviceId: BleDeviceId = event.peripheral.deviceId
54 | override val rssi: Int = event.RSSI.intValue
55 | override val isConnectable: Boolean =
56 | (event.advertisementData[CBAdvertisementDataIsConnectable] as? Boolean) ?: true
57 | }
58 |
59 | init {
60 | scope.launch {
61 | hardware.delegate.didDiscoverPeripheral.collect { event ->
62 | scanResults.send(MyScanResult(event))
63 | }
64 | }
65 | }
66 | }
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/MemberHeartRateIndicator.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.animation.core.Animatable
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.foundation.layout.size
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.ThumbUp
9 | import androidx.compose.material.icons.filled.Warning
10 | import androidx.compose.material3.Icon
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.runtime.rememberCoroutineScope
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.draw.alpha
17 | import androidx.compose.ui.graphics.Color
18 | import androidx.compose.ui.unit.dp
19 | import io.sellmair.pacemaker.UserState
20 | import io.sellmair.pacemaker.displayColorLight
21 | import io.sellmair.pacemaker.model.HeartRate
22 | import io.sellmair.pacemaker.ui.toColor
23 | import kotlinx.coroutines.launch
24 |
25 |
26 | @Composable
27 | internal fun MemberHeartRateIndicator(member: UserState, range: ClosedRange) {
28 | val side = if (member.isMe) ScaleSide.Right else ScaleSide.Left
29 | val memberCurrentHeartRate = member.heartRate
30 |
31 | val animatableHeartRate = remember { Animatable(memberCurrentHeartRate.value) }
32 | rememberCoroutineScope().launch {
33 | animatableHeartRate.animateTo(
34 | member.heartRate.value,
35 | animationSpec = onHeartRateScaleSpring()
36 | )
37 | }
38 |
39 | /* Can be null for myself */
40 | OnHeartRateScalePosition(
41 | HeartRate(animatableHeartRate.value), range, side = side, modifier = Modifier.padding(
42 | start = if (side == ScaleSide.Right) 96.dp else 0.dp,
43 | end = if (side == ScaleSide.Left) 48.dp else 0.dp
44 | )
45 | ) {
46 |
47 | Row(verticalAlignment = Alignment.CenterVertically) {
48 | UserHead(
49 | userState = member,
50 | modifier = Modifier
51 | .padding(horizontal = 4.dp)
52 | .alpha(0.75f)
53 | )
54 |
55 | member.heartRateLimit?.let { memberHeartRateLimit ->
56 | if (memberCurrentHeartRate > memberHeartRateLimit) {
57 | Icon(
58 | Icons.Default.Warning, "Too high",
59 | modifier = Modifier.size(12.dp),
60 | tint = Color.Red
61 | )
62 | } else {
63 | Icon(
64 | Icons.Default.ThumbUp, "OK",
65 | modifier = Modifier.size(12.dp),
66 | tint = member.displayColorLight.toColor()
67 | )
68 | }
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/dependencies.toml:
--------------------------------------------------------------------------------
1 | # Version catalog converted from buildSrc/src/main/kotlin/Dependencies.kt
2 |
3 | [versions]
4 | kotlin = "2.2.20"
5 | agp = "8.11.1"
6 | compose = "1.9.0-rc02"
7 | composeHotReload = "1.0.0-beta06"
8 | coroutines = "1.10.1"
9 | okio = "3.10.2"
10 | kotlinxDatetime = "0.7.1"
11 | kotlinxImmutable = "0.4.0"
12 | multiplatformSettings = "1.3.0"
13 | evas = "1.3.0"
14 | sqldelight = "2.1.0"
15 | atomicFu = "0.29.0"
16 | androidxCoreKtx = "1.13.1"
17 | androidxActivityCompose = "1.9.3"
18 |
19 |
20 | [libraries]
21 | # kotlinx-coroutines
22 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
23 | coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
24 | coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
25 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
26 |
27 | okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
28 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
29 | kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" }
30 | multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
31 |
32 | sqldelight-gradlePlugin = { module = "app.cash.sqldelight:gradle-plugin", version.ref = "sqldelight" }
33 | sqldelight-androidDriver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight"}
34 | sqldelight-sqliteDriver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight"}
35 | sqldelight-nativeDriver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight"}
36 | sqldelight-coroutineExtensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight"}
37 |
38 | androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" }
39 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" }
40 |
41 | # Evas
42 | evas = { module = "io.sellmair:evas", version.ref = "evas" }
43 | evas-compose = { module = "io.sellmair:evas-compose", version.ref = "evas" }
44 |
45 | kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
46 | kotlin-composeCompilerPlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
47 | android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
48 | compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose" }
49 | compose-hotReload-gradlePlugin = { module = "org.jetbrains.compose.hot-reload:hot-reload-gradle-plugin", version.ref = "composeHotReload" }
50 | atomicFu-gradlePlugin = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", version.ref = "atomicFu" }
51 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/sessionActor.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.Event
4 | import io.sellmair.evas.State
5 | import io.sellmair.evas.collectEvents
6 | import io.sellmair.evas.launchState
7 | import io.sellmair.pacemaker.ActiveSessionIntent.Start
8 | import io.sellmair.pacemaker.ActiveSessionIntent.Stop
9 | import io.sellmair.pacemaker.bluetooth.HeartRateMeasurementEvent
10 | import io.sellmair.pacemaker.bluetooth.PacemakerBroadcastPackageEvent
11 | import io.sellmair.pacemaker.model.Session
12 | import kotlinx.coroutines.CoroutineScope
13 | import kotlinx.coroutines.Job
14 | import kotlinx.coroutines.coroutineScope
15 | import kotlinx.coroutines.launch
16 |
17 | data class ActiveSessionState(val session: Session?) : State {
18 | companion object : State.Key {
19 | override val default: ActiveSessionState = ActiveSessionState(null)
20 | }
21 | }
22 |
23 | sealed interface ActiveSessionIntent : Event {
24 | data object Start : ActiveSessionIntent
25 | data object Stop : ActiveSessionIntent
26 | }
27 |
28 | internal fun CoroutineScope.launchSessionActor(
29 | userService: UserService, sessionService: SessionService
30 | ) = launchState(ActiveSessionState) {
31 | var activeSession: ActiveSessionService? = null
32 | var activeSessionActor: Job? = null
33 |
34 | collectEvents { event ->
35 | when (event) {
36 | Start -> {
37 | activeSession?.stop()
38 | activeSessionActor?.cancel()
39 | val activeSessionService = sessionService.createSession()
40 | activeSession = activeSessionService
41 | ActiveSessionState(activeSession?.session).emit()
42 | activeSessionActor = launchActiveSessionActor(userService, activeSessionService)
43 | }
44 |
45 | Stop -> {
46 | activeSession?.stop()
47 | activeSessionActor?.cancel()
48 | ActiveSessionState(null).emit()
49 | }
50 | }
51 | }
52 | }
53 |
54 |
55 | internal fun CoroutineScope.launchActiveSessionActor(
56 | userService: UserService,
57 | activeSessionService: ActiveSessionService
58 | ) = launch {
59 | coroutineScope {
60 | launch {
61 | collectEvents { event ->
62 | val user = userService.findUser(event.pkg.userId) ?: return@collectEvents
63 | activeSessionService.save(user, event.pkg.heartRate, event.pkg.heartRateLimit, event.pkg.receivedTime)
64 | }
65 | }
66 |
67 | launch {
68 | collectEvents { event ->
69 | val user = userService.findUser(event.sensorId) ?: return@collectEvents
70 | val heartRateLimit = userService.findHeartRateLimit(user)
71 | activeSessionService.save(user, event.heartRate, heartRateLimit, event.time)
72 | }
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/spoof-tool/src/macosMain/kotlin/io.sellmair.broadheart.spoof/Main.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.spoof
2 |
3 | import io.sellmair.pacemaker.ble.AppleBle
4 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService
5 | import io.sellmair.pacemaker.model.HeartRate
6 | import io.sellmair.pacemaker.model.User
7 | import io.sellmair.pacemaker.model.UserId
8 | import io.sellmair.pacemaker.utils.LogTag
9 | import io.sellmair.pacemaker.utils.info
10 | import kotlinx.coroutines.*
11 | import platform.CoreFoundation.CFRunLoopRun
12 |
13 |
14 | private val ble = AppleBle()
15 |
16 |
17 | fun main() {
18 | launchSendBroadcasts()
19 | //launchReceiveBroadcasts()
20 | /* launchReceiveHeartRates() */
21 | CFRunLoopRun()
22 | }
23 |
24 | private fun launchSendBroadcasts() = MainScope().launch {
25 | MainScope().launch(Dispatchers.Default) {
26 | val user = User(
27 | id = UserId(2412),
28 | name = "Felix Werner"
29 | )
30 |
31 | val pacemakerPeripheral = PacemakerBluetoothService(ble)
32 | pacemakerPeripheral.write {
33 | setUser(user)
34 | setHeartRateLimit(HeartRate(120))
35 | setHeartRate(HeartRate(130))
36 | }
37 |
38 |
39 | while (isActive) {
40 | yield()
41 | val line = readln()
42 | if (line.startsWith("l")) {
43 | val heartRateLimit = line.removePrefix("l").toIntOrNull() ?: continue
44 | pacemakerPeripheral.write {
45 | setHeartRateLimit(HeartRate(heartRateLimit))
46 | println("Updated spoof hr-limit: $heartRateLimit")
47 | }
48 |
49 | } else {
50 | pacemakerPeripheral.write {
51 | setHeartRate(HeartRate(line.toIntOrNull() ?: return@write))
52 | println("Updated spoof hr: ${line.toIntOrNull()}")
53 | }
54 | }
55 | }
56 | }
57 | }
58 |
59 | private fun launchReceiveBroadcasts() = MainScope().launch(Dispatchers.Default) {
60 | val pacemaker = PacemakerBluetoothService(ble)
61 | pacemaker.newConnections.collect { connection ->
62 | LogTag("spoof").info("Received connection ${connection.deviceId}")
63 | }
64 | }
65 |
66 | /*
67 | private fun launchReceiveBroadcasts() = MainScope().launch(Dispatchers.Default) {
68 | val pacemakerBle = PacemakerBle(ble)
69 | pacemakerBle.connections.flatMapMerge { connection ->
70 | println("Found pacemaker peripheral: ${connection.id}")
71 | connection.receivePacemakerBroadcastPackages()
72 | }.collect { pkg ->
73 | println("${pkg.userName}: ${pkg.heartRate.value.roundToInt()}/${pkg.heartRateLimit.value.roundToInt()}")
74 | }
75 | }
76 |
77 | private fun launchReceiveHeartRates() = MainScope().launch(Dispatchers.Default) {/*
78 | Ble(this).receiveHeartRateMeasurements().collect { measurement ->
79 | println("HR: ${measurement.heartRate} | Device: ${measurement.sensorInfo.id}")
80 | }*/
81 | }
82 |
83 |
84 | */
--------------------------------------------------------------------------------
/bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/ApplePeripheralManagerDelegate.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("unused")
2 |
3 | package io.sellmair.pacemaker.ble
4 |
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.flow.MutableSharedFlow
7 | import kotlinx.coroutines.flow.MutableStateFlow
8 | import kotlinx.coroutines.launch
9 | import platform.CoreBluetooth.CBATTRequest
10 | import platform.CoreBluetooth.CBPeripheralManager
11 | import platform.CoreBluetooth.CBPeripheralManagerDelegateProtocol
12 | import platform.CoreBluetooth.CBService
13 | import platform.Foundation.NSError
14 | import platform.darwin.NSObject
15 |
16 | internal class ApplePeripheralManagerDelegate(
17 | private val scope: CoroutineScope
18 | ) : NSObject(), CBPeripheralManagerDelegateProtocol {
19 |
20 | private val thisDelegate = this
21 |
22 | /* State */
23 |
24 | val state = MutableStateFlow(null)
25 |
26 | override fun peripheralManagerDidUpdateState(peripheral: CBPeripheralManager) {
27 | state.value = peripheral.state
28 | }
29 |
30 |
31 | /* Is ready to update subscribers */
32 |
33 | val isReadyToUpdateSubscribers = MutableSharedFlow()
34 |
35 | override fun peripheralManagerIsReadyToUpdateSubscribers(peripheral: CBPeripheralManager) {
36 | scope.launch {
37 | thisDelegate.isReadyToUpdateSubscribers.emit(Unit)
38 | }
39 | }
40 |
41 | /* Did receive read request */
42 |
43 | class DidReceiveReadRequest(val peripheral: CBPeripheralManager, val request: CBATTRequest)
44 |
45 | val didReceiveReadRequest = MutableSharedFlow()
46 |
47 | override fun peripheralManager(peripheral: CBPeripheralManager, didReceiveReadRequest: CBATTRequest) {
48 | scope.launch {
49 | thisDelegate.didReceiveReadRequest.emit(DidReceiveReadRequest(peripheral, didReceiveReadRequest))
50 | }
51 | }
52 |
53 | /* Did receive write request */
54 |
55 | class DidReceiveWriteRequest(
56 | val peripheral: CBPeripheralManager, val request: CBATTRequest
57 | )
58 |
59 | val didReceiveWriteRequest = MutableSharedFlow()
60 |
61 | override fun peripheralManager(peripheral: CBPeripheralManager, didReceiveWriteRequests: List<*>) {
62 | didReceiveWriteRequests.forEach { writeRequest ->
63 | scope.launch {
64 | writeRequest as CBATTRequest
65 | thisDelegate.didReceiveWriteRequest.emit(DidReceiveWriteRequest(peripheral, writeRequest))
66 | }
67 | }
68 | }
69 |
70 | /* Did add service */
71 |
72 | class DidAddService(val peripheral: CBPeripheralManager, val service: CBService, val error: NSError?)
73 |
74 | val didAddService = MutableSharedFlow()
75 |
76 | override fun peripheralManager(peripheral: CBPeripheralManager, didAddService: CBService, error: NSError?) {
77 | scope.launch {
78 | thisDelegate.didAddService.emit(DidAddService(peripheral, didAddService, error))
79 | }
80 | }
81 |
82 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if %ERRORLEVEL% equ 0 goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if %ERRORLEVEL% equ 0 goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | set EXIT_CODE=%ERRORLEVEL%
84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
86 | exit /b %EXIT_CODE%
87 |
88 | :mainEnd
89 | if "%OS%"=="Windows_NT" endlocal
90 |
91 | :omega
92 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/UtteranceControlButton.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.mainPage
2 |
3 | import androidx.compose.animation.*
4 | import androidx.compose.foundation.ExperimentalFoundationApi
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.automirrored.filled.VolumeOff
8 | import androidx.compose.material.icons.automirrored.filled.VolumeUp
9 | import androidx.compose.material.icons.filled.Warning
10 | import androidx.compose.material3.Button
11 | import androidx.compose.material3.ButtonDefaults
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.LaunchedEffect
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.graphics.Color
19 | import androidx.compose.ui.unit.dp
20 | import io.sellmair.evas.compose.EvasLaunching
21 | import io.sellmair.evas.compose.composeState
22 | import io.sellmair.evas.set
23 | import io.sellmair.pacemaker.UtteranceState
24 | import io.sellmair.pacemaker.ui.MeColor
25 | import io.sellmair.pacemaker.ui.MeColorLight
26 |
27 | @Composable
28 | fun UtteranceControlButton(modifier: Modifier = Modifier) {
29 | val utteranceState by UtteranceState.composeState()
30 | UtteranceControlButton(
31 | state = utteranceState,
32 | modifier = modifier,
33 | onClick = EvasLaunching {
34 | UtteranceState.set(utteranceState.next())
35 | },
36 | )
37 | }
38 |
39 | @OptIn(ExperimentalFoundationApi::class)
40 | @Composable
41 | fun UtteranceControlButton(
42 | state: UtteranceState,
43 | modifier: Modifier = Modifier,
44 | onClick: () -> Unit = {},
45 | ) {
46 | val desiredColor = when (state) {
47 | UtteranceState.Silence -> Color.Gray
48 | UtteranceState.Warnings -> MeColorLight()
49 | UtteranceState.All -> MeColor()
50 | }
51 |
52 | val color = remember { Animatable(desiredColor) }
53 |
54 | LaunchedEffect(desiredColor) {
55 | color.animateTo(desiredColor)
56 | }
57 |
58 | Button(
59 | colors = ButtonDefaults.buttonColors(
60 | containerColor = color.value
61 | ),
62 | onClick = onClick,
63 | modifier = modifier
64 | .padding(18.dp)
65 | ) {
66 |
67 | AnimatedContent(
68 | targetState = state,
69 | transitionSpec = {
70 | (slideInHorizontally { it } + fadeIn() + scaleIn())
71 | .togetherWith(slideOutHorizontally { -it } + fadeOut() + scaleOut())
72 | },
73 | ) { targetState ->
74 | Icon(
75 | imageVector = when (targetState) {
76 | UtteranceState.Silence -> Icons.AutoMirrored.Default.VolumeOff
77 | UtteranceState.Warnings -> Icons.Default.Warning
78 | UtteranceState.All -> Icons.AutoMirrored.Default.VolumeUp
79 | },
80 | contentDescription = null,
81 | )
82 | }
83 | }
84 | }
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/timelinePage/TimelinePage.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.timelinePage
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Spacer
5 | import androidx.compose.foundation.layout.defaultMinSize
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.lazy.LazyColumn
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.text.font.FontWeight
15 | import androidx.compose.ui.text.style.TextAlign
16 | import androidx.compose.ui.unit.dp
17 | import io.sellmair.evas.compose.composeValue
18 | import io.sellmair.pacemaker.SessionsState
19 | import io.sellmair.pacemaker.model.Session
20 | import io.sellmair.pacemaker.ui.widget.Headline
21 | import kotlin.time.Clock
22 | import kotlinx.datetime.LocalDateTime
23 | import kotlinx.datetime.TimeZone
24 | import kotlinx.datetime.toLocalDateTime
25 |
26 | @Composable
27 | fun TimelinePage() {
28 | val sessions = SessionsState.composeValue().sessions
29 | TimelinePage(sessions)
30 | }
31 |
32 | @Composable
33 | fun TimelinePage(sessions: List) {
34 | LazyColumn(Modifier.fillMaxWidth()) {
35 | item {
36 | Headline("History", Modifier.padding(24.dp))
37 | }
38 |
39 | if (sessions.isEmpty()) {
40 | item {
41 | EmptyTimelinePlaceholder()
42 | }
43 | } else {
44 | sessions.forEach { session ->
45 | item(session.id.value) {
46 | SessionItem(session)
47 | }
48 | }
49 | }
50 | }
51 | }
52 |
53 |
54 | @Composable
55 | private fun EmptyTimelinePlaceholder() {
56 | Text(
57 | "No run, yet",
58 | textAlign = TextAlign.Center,
59 | modifier = Modifier
60 | .fillMaxSize()
61 | .defaultMinSize(minHeight = 150.dp)
62 | )
63 | }
64 |
65 | @Composable
66 | private fun SessionItem(session: Session) {
67 | val startTime = session.startTime.toLocalDateTime(TimeZone.currentSystemDefault())
68 | Column(Modifier.padding(24.dp)) {
69 | Text(
70 | startTime.dateString(),
71 | fontWeight = FontWeight.Bold
72 | )
73 | Text(
74 | startTime.clockString(),
75 | fontWeight = FontWeight.Light
76 | )
77 | Spacer(Modifier.height(12.dp))
78 | }
79 | }
80 |
81 | private fun LocalDateTime.dateString(): String = buildString {
82 | append(dayOfMonth.twoDigitString())
83 | append(".")
84 | append(monthNumber.twoDigitString())
85 | if (year != Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year) {
86 | append(".${year}")
87 | }
88 | }
89 |
90 | private fun LocalDateTime.clockString(): String = "${hour.twoDigitString()}:${minute.twoDigitString()}"
91 |
92 | private fun Int.twoDigitString(): String {
93 | return if (this >= 10) this.toString()
94 | else "0$this"
95 | }
96 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SqlSessionService.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import app.cash.sqldelight.coroutines.asFlow
4 | import io.sellmair.pacemaker.model.HeartRate
5 | import io.sellmair.pacemaker.model.Session
6 | import io.sellmair.pacemaker.model.SessionId
7 | import io.sellmair.pacemaker.model.User
8 | import io.sellmair.pacemaker.model.UserId
9 | import io.sellmair.pacemaker.utils.value
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.map
12 | import kotlin.time.Instant
13 |
14 | internal class SqlSessionService(private val database: SafePacemakerDatabase) : SessionService {
15 | override suspend fun createSession(): ActiveSessionService = database {
16 | val startTime = SessionService.SessionClock.value().now()
17 | transactionWithResult {
18 | sessionQueries.newSession(startTime.toString())
19 | val id = sessionQueries.lastSessionId().executeAsOne()
20 | SqlActiveSessionService(Session(SessionId(id), startTime, null), database)
21 | }
22 | }
23 |
24 | override suspend fun getSessions(): List {
25 | return database {
26 | sessionQueries.allSessions().executeAsList().map { dbSession ->
27 | SqlStoredSessionService(
28 | session = dbSession.toSession(),
29 | database = database
30 | )
31 | }
32 | }
33 | }
34 |
35 | override val sessionsFlow: Flow> = database.flow {
36 | sessionQueries.allSessions()
37 | .asFlow().map { query -> query.executeAsList() }
38 | .map { values -> values.map { it.toStroedSessionService() } }
39 | }
40 |
41 | private fun Db_session.toStroedSessionService() = SqlStoredSessionService(
42 | session = toSession(),
43 | database = database
44 | )
45 |
46 | }
47 |
48 | private class SqlStoredSessionService(
49 | override val session: Session,
50 | private val database: SafePacemakerDatabase
51 | ) : StoredSessionService {
52 |
53 | override suspend fun getUsers(): List {
54 | return database {
55 | sessionQueries.findUsers(session_id = session.id.value).executeAsList()
56 | .mapNotNull { userId -> userQueries.findUserById(userId).executeAsOneOrNull() }
57 | .map { it.toUser() }
58 | }
59 | }
60 |
61 | override suspend fun getHeartRateMeasurements(user: User): List = database {
62 | sessionQueries.findHeartRateMeasurements(session.id.value).executeAsList().map { dbRecord ->
63 | SessionRecord(
64 | sessionId = session.id,
65 | userId = UserId(dbRecord.user_id),
66 | time = Instant.parse(dbRecord.time),
67 | heartRate = HeartRate(dbRecord.heart_rate.toFloat()),
68 | heartRateLimit = dbRecord.heart_rate_limit?.let { HeartRate(it.toFloat()) }
69 | )
70 | }
71 | }
72 | }
73 |
74 | private fun Db_session.toSession() = Session(
75 | id = SessionId(id),
76 | startTime = Instant.parse(start_time),
77 | endTime = end_time?.let { Instant.parse(end_time) }
78 | )
79 |
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/MyStatusHeader.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.mainPage
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.outlined.FavoriteBorder
7 | import androidx.compose.material3.Icon
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.geometry.Offset
13 | import androidx.compose.ui.graphics.Brush
14 | import androidx.compose.ui.graphics.Color
15 | import androidx.compose.ui.text.font.FontWeight
16 | import androidx.compose.ui.unit.dp
17 | import androidx.compose.ui.unit.sp
18 | import io.sellmair.evas.compose.composeValue
19 | import io.sellmair.pacemaker.MeState
20 | import io.sellmair.pacemaker.ui.MeColor
21 | import io.sellmair.pacemaker.ui.MeColorLight
22 | import io.sellmair.pacemaker.ui.widget.experimentalFeatureToggle
23 |
24 | @Composable
25 | fun MyStatusHeader() {
26 | MyStatusHeader(MeState.composeValue())
27 | }
28 |
29 |
30 | @Composable
31 | fun MyStatusHeader(state: MeState?) {
32 | Box {
33 | Column(
34 | Modifier
35 | .fillMaxWidth()
36 | .height(248.dp)
37 | .background(
38 | Brush.linearGradient(
39 | listOf(Color.White, Color.White, Color.Transparent),
40 | start = Offset.Zero,
41 | end = Offset(0f, Float.POSITIVE_INFINITY)
42 | )
43 | ),
44 | horizontalAlignment = Alignment.CenterHorizontally
45 | ) {
46 |
47 | /* Ensure big HR number and Settings icon are aligned vertically */
48 | Box(
49 | Modifier
50 | .fillMaxWidth()
51 | .height(IntrinsicSize.Min)
52 | ) {
53 | SessionStartStopButton(
54 | Modifier.align(Alignment.CenterStart)
55 | )
56 |
57 | Text(
58 | state?.heartRate?.toString() ?: "🤷♂️",
59 | fontWeight = FontWeight.Black,
60 | fontSize = 48.sp,
61 | modifier = Modifier
62 | .padding(top = 8.dp)
63 | .align(Alignment.Center)
64 | .experimentalFeatureToggle()
65 | )
66 |
67 | UtteranceControlButton(
68 | Modifier.align(Alignment.CenterEnd)
69 | )
70 | }
71 |
72 | if (state?.heartRateLimit != null)
73 | Text(
74 | state.heartRateLimit.toString(),
75 | Modifier.offset(y = (-4).dp),
76 | fontWeight = FontWeight.Light,
77 | fontSize = 10.sp,
78 | color = MeColor()
79 | )
80 |
81 | Icon(
82 | Icons.Outlined.FavoriteBorder, "Heart",
83 | Modifier.offset(y = (-4).dp),
84 | tint = MeColorLight()
85 | )
86 | }
87 | }
88 | }
--------------------------------------------------------------------------------
/app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/HeartRateScale.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker.ui.widget
2 |
3 | import androidx.compose.foundation.Canvas
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.draw.clipToBounds
9 | import androidx.compose.ui.geometry.Offset
10 | import androidx.compose.ui.graphics.Color
11 | import androidx.compose.ui.graphics.SolidColor
12 | import androidx.compose.ui.text.font.FontWeight
13 | import androidx.compose.ui.unit.dp
14 | import androidx.compose.ui.unit.sp
15 | import io.sellmair.pacemaker.model.HeartRate
16 | import io.sellmair.pacemaker.model.step
17 | import kotlin.math.roundToInt
18 |
19 |
20 | @Composable
21 | internal fun HeartRateScale(
22 | modifier: Modifier = Modifier,
23 | range: ClosedRange = HeartRate(40)..HeartRate(200f),
24 | horizontalCenterBias: Float = .5f,
25 | content: @Composable () -> Unit = {},
26 | ) {
27 | Box(modifier.clipToBounds()) {
28 | Canvas(
29 | modifier = Modifier.fillMaxSize()
30 | ) {
31 | val center = drawContext.size.run {
32 | Offset(
33 | x = horizontalCenterBias * width,
34 | y = height / 2f
35 | )
36 | }
37 | val brush = SolidColor(Color.Gray)
38 | drawLine(brush, Offset(center.x, 0f), Offset(center.x, size.height), strokeWidth = 3f)
39 |
40 | (range step 10).forEach { heartRate ->
41 | val y = yOfHeartRate(HeartRate(heartRate), range, size.height)
42 | drawLine(brush, Offset(center.x - 20, y), Offset(center.x + 20, y), strokeWidth = 2f)
43 | }
44 | }
45 |
46 | (range step 5).map(::HeartRate).forEach { heartRate ->
47 | OnHeartRateScalePosition(
48 | heartRate = heartRate,
49 | range = range,
50 | horizontalCenterBias = horizontalCenterBias,
51 | modifier = Modifier.padding(start = 24.dp)
52 | ) {
53 | val isEmphasized = heartRate.value.roundToInt() % 10 == 0
54 | Text(
55 | heartRate.toString(),
56 | fontWeight = if (isEmphasized) FontWeight.Bold else FontWeight.Thin,
57 | fontSize = if (isEmphasized) 12.sp else 10.sp,
58 | )
59 | }
60 | }
61 |
62 | content()
63 | }
64 | }
65 |
66 | fun yOfHeartRate(heartRate: HeartRate, range: ClosedRange, height: Float): Float {
67 | if (heartRate > range.endInclusive) return 0f
68 | if (heartRate < range.start) return height
69 | val rangeWidth = range.endInclusive.value - range.start.value
70 | val relativeInRange = (heartRate.value - range.start.value) / rangeWidth
71 | return height * (1f - relativeInRange)
72 | }
73 |
74 | fun heartRateOfY(y: Float, range: ClosedRange, height: Float): HeartRate {
75 | if (y > height) return range.start
76 | if (y < 0) return range.endInclusive
77 | val rangeWidth = range.endInclusive.value - range.start.value
78 | val hr = range.endInclusive.value - (rangeWidth * y) / height
79 | return HeartRate(hr)
80 | }
81 |
--------------------------------------------------------------------------------
/app-core/src/androidUnitTest/kotlin/GroupStateTest.kt:
--------------------------------------------------------------------------------
1 | import io.sellmair.evas.*
2 | import io.sellmair.pacemaker.*
3 | import io.sellmair.pacemaker.bluetooth.HeartRateMeasurementEvent
4 | import io.sellmair.pacemaker.model.HeartRate
5 | import io.sellmair.pacemaker.model.HeartRateSensorId
6 | import io.sellmair.pacemaker.model.UserId
7 | import io.sellmair.pacemaker.utils.value
8 | import kotlinx.coroutines.ExperimentalCoroutinesApi
9 | import kotlinx.coroutines.cancelChildren
10 | import kotlinx.coroutines.currentCoroutineContext
11 | import kotlinx.coroutines.flow.first
12 | import kotlinx.coroutines.launch
13 | import kotlinx.coroutines.test.TestScope
14 | import kotlinx.coroutines.test.runTest
15 | import utils.createInMemoryDatabase
16 | import kotlin.test.Test
17 | import kotlin.test.assertEquals
18 | import kotlin.time.Clock
19 |
20 | @OptIn(ExperimentalCoroutinesApi::class)
21 | class GroupStateTest {
22 |
23 | private val userService = SqlUserService(createInMemoryDatabase(), UserId(1))
24 |
25 | @Test
26 | fun `sample 0`() = test {
27 | val meSensorId = HeartRateSensorId("me")
28 | val me = userService.me()
29 | userService.linkSensor(me, meSensorId)
30 | HeartRateMeasurementEvent(HeartRate(128f), meSensorId, Clock.System.now()).emit()
31 |
32 | launch {
33 | GroupState.collect { println("(${testScheduler.currentTime}ms) s: $it") }
34 | }
35 |
36 | val state = GroupState.flow().first { it.members.isNotEmpty() }
37 | println("(${testScheduler.currentTime}ms) Received first state with non-empty members")
38 |
39 | assertEquals(
40 | GroupState(
41 | listOf(
42 | UserState(
43 | user = me,
44 | isMe = true,
45 | heartRate = HeartRate(128f),
46 | heartRateLimit = UserService.NewUserHeartRateLimit.value(),
47 | color = UserColors.default(me.id)
48 | )
49 | )
50 | ), state,
51 | "Expect first state with non-empty members to reflect the previously emitted '${HeartRateMeasurementEvent::class}'"
52 | )
53 |
54 | println("(${testScheduler.currentTime}ms) Testing current state value...")
55 | assertEquals(
56 | state, GroupState.value(),
57 | "(${testScheduler.currentTime}ms) Expect the current value of 'GroupState' to be the recently emitted state"
58 | )
59 |
60 | println("(${testScheduler.currentTime}ms) Waiting until the Group state resets..")
61 | GroupState.flow().first { it == GroupState.default }
62 |
63 | println("(${testScheduler.currentTime}ms) Group state was reset!")
64 | assertEquals(
65 | GroupState.KeepMeasurementDuration.value().inWholeMilliseconds,
66 | testScheduler.currentTime,
67 | "Expect that the default value was emitted after the correct time"
68 | )
69 | }
70 |
71 | private fun test(block: suspend TestScope.() -> Unit) = runTest(Events() + States()) {
72 | try {
73 | launchGroupStateActor(userService, actorContext = currentCoroutineContext())
74 | block()
75 | } finally {
76 | currentCoroutineContext().cancelChildren()
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchPacemakerBroadcastSender.kt:
--------------------------------------------------------------------------------
1 | package io.sellmair.pacemaker
2 |
3 | import io.sellmair.evas.flow
4 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService
5 | import io.sellmair.pacemaker.model.Hue
6 | import io.sellmair.evas.value
7 | import kotlinx.coroutines.*
8 | import kotlinx.coroutines.flow.conflate
9 | import kotlinx.coroutines.flow.distinctUntilChanged
10 | import kotlinx.coroutines.flow.mapNotNull
11 | import kotlinx.coroutines.flow.sample
12 | import kotlin.time.Duration.Companion.milliseconds
13 | import kotlin.time.Duration.Companion.seconds
14 |
15 | @OptIn(FlowPreview::class)
16 | internal fun CoroutineScope.launchPacemakerBroadcastSender(
17 | pacemakerBluetoothService: Deferred
18 | ) {
19 | /* Start broadcasting my own state to other participant */
20 |
21 | /* Broadcast changes to 'me' */
22 | launch {
23 | MeState.flow().mapNotNull { state -> state?.me }
24 | .distinctUntilChanged()
25 | .conflate()
26 | .collect { me ->
27 | pacemakerBluetoothService.await().write {
28 | setUser(me)
29 | }
30 | }
31 | }
32 |
33 | /* Broadcast changes to 'heartRateLimit' */
34 | launch {
35 | MeState.flow().mapNotNull { state -> state?.heartRateLimit }
36 | .sample(32.milliseconds)
37 | .distinctUntilChanged()
38 | .conflate()
39 | .collect { heartRateLimit ->
40 | pacemakerBluetoothService.await().write {
41 | setHeartRateLimit(heartRateLimit)
42 | }
43 | }
44 | }
45 |
46 | /* Broadcast changes to 'heartRate' */
47 | launch {
48 | MeState.flow().mapNotNull { state -> state?.heartRate }
49 | .sample(32.milliseconds)
50 | .distinctUntilChanged()
51 | .conflate()
52 | .collect { heartRate ->
53 | pacemakerBluetoothService.await().write {
54 | setHeartRate(heartRate)
55 | }
56 | }
57 | }
58 |
59 | /* Broadcast my current color */
60 | launch {
61 | MeColorState.flow().mapNotNull { state -> state?.color }
62 | .sample(32.milliseconds)
63 | .distinctUntilChanged()
64 | .conflate()
65 | .collect { meColor ->
66 | pacemakerBluetoothService.await().write {
67 | setColorHue(Hue.safe(meColor.hue))
68 | }
69 | }
70 | }
71 |
72 | /* Fallback: Broadcast regardless of changes (in case a previous broadcast was lost) */
73 | launch {
74 | while (isActive) {
75 | delay(15.seconds)
76 |
77 | MeState.value()?.let { state ->
78 | pacemakerBluetoothService.await().write {
79 | setUser(state.me)
80 | setHeartRateLimit(state.heartRateLimit)
81 | state.heartRate?.let { setHeartRate(it) }
82 | }
83 | }
84 |
85 | MeColorState.value()?.let { state ->
86 | pacemakerBluetoothService.await().write {
87 | setColorHue(Hue.safe(state.color.hue))
88 | }
89 | }
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------