Initial commit
This commit is contained in:
551
skills/sleeptrack-android/SKILL.md
Normal file
551
skills/sleeptrack-android/SKILL.md
Normal file
@@ -0,0 +1,551 @@
|
||||
---
|
||||
name: sleeptrack-android
|
||||
description: This skill helps Android developers integrate the Asleep SDK for sleep tracking functionality. Use this skill when developers ask about Android implementation, MVVM architecture patterns, permission handling, Jetpack Compose UI, Kotlin coroutines integration, or Android-specific sleep tracking features. This skill provides working code examples from the official sample app.
|
||||
---
|
||||
|
||||
# Sleeptrack Android
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides comprehensive guidance for integrating the Asleep sleep tracking SDK into Android applications. It covers Android-specific implementation details including MVVM architecture, permission handling, state management, UI patterns, and lifecycle management.
|
||||
|
||||
Use this skill when developers need to:
|
||||
- Set up Asleep SDK in Android projects
|
||||
- Implement sleep tracking with proper Android architecture
|
||||
- Handle Android-specific permissions (microphone, notifications, battery optimization)
|
||||
- Manage tracking lifecycle with state machines
|
||||
- Build UI with ViewBinding or Jetpack Compose
|
||||
- Handle errors and edge cases in Android environment
|
||||
- Implement foreground services for background tracking
|
||||
|
||||
**Prerequisites**: Review the `sleeptrack-foundation` skill first for core concepts including session lifecycle, error codes, and API fundamentals.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Dependencies
|
||||
|
||||
Add to your app-level `build.gradle`:
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
// Core dependencies
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
|
||||
// Required for Asleep SDK
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||
implementation 'com.google.code.gson:gson:2.10'
|
||||
implementation 'ai.asleep:asleepsdk:3.1.4'
|
||||
|
||||
// Optional: Hilt for DI
|
||||
implementation "com.google.dagger:hilt-android:2.48"
|
||||
kapt "com.google.dagger:hilt-compiler:2.48"
|
||||
}
|
||||
```
|
||||
|
||||
**Minimum Requirements**:
|
||||
- minSdk: 24 (Android 7.0)
|
||||
- targetSdk: 34
|
||||
- Java: 17
|
||||
- Kotlin: 1.9.24+
|
||||
|
||||
See `references/gradle_setup.md` for complete Gradle configuration.
|
||||
|
||||
### 2. Configure Permissions
|
||||
|
||||
Add to `AndroidManifest.xml`:
|
||||
|
||||
```xml
|
||||
<manifest>
|
||||
<!-- Essential permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<!-- Battery optimization -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- Notifications (Android 13+) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
</manifest>
|
||||
```
|
||||
|
||||
### 3. Initialize SDK
|
||||
|
||||
```kotlin
|
||||
Asleep.initAsleepConfig(
|
||||
context = applicationContext,
|
||||
apiKey = "your_api_key_here",
|
||||
userId = "unique_user_id",
|
||||
baseUrl = "https://api.asleep.ai",
|
||||
callbackUrl = null, // Optional webhook URL
|
||||
service = "YourAppName",
|
||||
asleepConfigListener = object : Asleep.AsleepConfigListener {
|
||||
override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) {
|
||||
// SDK initialized successfully
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
Log.e("Asleep", "Init failed: $errorCode - $detail")
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Start Sleep Tracking
|
||||
|
||||
```kotlin
|
||||
Asleep.beginSleepTracking(
|
||||
asleepConfig = asleepConfig,
|
||||
asleepTrackingListener = object : Asleep.AsleepTrackingListener {
|
||||
override fun onStart(sessionId: String) {
|
||||
// Tracking started successfully
|
||||
}
|
||||
|
||||
override fun onPerform(sequence: Int) {
|
||||
// Called every ~30 seconds (1 sequence)
|
||||
}
|
||||
|
||||
override fun onFinish(sessionId: String?) {
|
||||
// Tracking completed
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
// Handle tracking errors
|
||||
}
|
||||
},
|
||||
notificationTitle = "Sleep Tracking Active",
|
||||
notificationText = "Tap to return to app",
|
||||
notificationIcon = R.drawable.ic_notification,
|
||||
notificationClass = MainActivity::class.java
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Stop Tracking
|
||||
|
||||
```kotlin
|
||||
Asleep.endSleepTracking()
|
||||
```
|
||||
|
||||
## Android Architecture Pattern (MVVM + Hilt)
|
||||
|
||||
The recommended architecture follows Android best practices with MVVM pattern, Hilt dependency injection, and proper state management.
|
||||
|
||||
### State Management
|
||||
|
||||
Define tracking states using sealed classes:
|
||||
|
||||
```kotlin
|
||||
sealed class AsleepState {
|
||||
data object STATE_IDLE: AsleepState()
|
||||
data object STATE_INITIALIZING : AsleepState()
|
||||
data object STATE_INITIALIZED : AsleepState()
|
||||
data object STATE_TRACKING_STARTING : AsleepState()
|
||||
data object STATE_TRACKING_STARTED : AsleepState()
|
||||
data object STATE_TRACKING_STOPPING : AsleepState()
|
||||
data class STATE_ERROR(val errorCode: AsleepError) : AsleepState()
|
||||
}
|
||||
|
||||
data class AsleepError(val code: Int, val message: String)
|
||||
```
|
||||
|
||||
### Basic ViewModel Pattern
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class SleepTrackingViewModel @Inject constructor(
|
||||
@ApplicationContext private val app: Application
|
||||
) : ViewModel() {
|
||||
|
||||
private val _trackingState = MutableStateFlow<AsleepState>(AsleepState.STATE_IDLE)
|
||||
val trackingState: StateFlow<AsleepState> = _trackingState
|
||||
|
||||
private var config: AsleepConfig? = null
|
||||
|
||||
fun initializeSDK(userId: String) {
|
||||
Asleep.initAsleepConfig(
|
||||
context = app,
|
||||
apiKey = BuildConfig.ASLEEP_API_KEY,
|
||||
userId = userId,
|
||||
baseUrl = "https://api.asleep.ai",
|
||||
callbackUrl = null,
|
||||
service = "MyApp",
|
||||
asleepConfigListener = object : Asleep.AsleepConfigListener {
|
||||
override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) {
|
||||
config = asleepConfig
|
||||
_trackingState.value = AsleepState.STATE_INITIALIZED
|
||||
}
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
_trackingState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun startTracking() {
|
||||
config?.let {
|
||||
Asleep.beginSleepTracking(
|
||||
asleepConfig = it,
|
||||
asleepTrackingListener = trackingListener,
|
||||
notificationTitle = "Sleep Tracking",
|
||||
notificationText = "Active",
|
||||
notificationIcon = R.drawable.ic_notification,
|
||||
notificationClass = MainActivity::class.java
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopTracking() {
|
||||
Asleep.endSleepTracking()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete production-ready ViewModel with real-time data, error handling, and lifecycle management**, see `references/complete_viewmodel_implementation.md`.
|
||||
|
||||
### Basic Activity Pattern
|
||||
|
||||
```kotlin
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val viewModel: SleepTrackingViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// Setup button
|
||||
binding.btnTrack.setOnClickListener {
|
||||
when (viewModel.trackingState.value) {
|
||||
AsleepState.STATE_INITIALIZED -> viewModel.startTracking()
|
||||
AsleepState.STATE_TRACKING_STARTED -> viewModel.stopTracking()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe state
|
||||
lifecycleScope.launch {
|
||||
viewModel.trackingState.collect { state ->
|
||||
updateUI(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete Activity implementation with permission handling**, see `references/complete_viewmodel_implementation.md`.
|
||||
|
||||
## Permission Handling
|
||||
|
||||
Android requires multiple runtime permissions for sleep tracking:
|
||||
|
||||
### Required Permissions
|
||||
|
||||
1. **RECORD_AUDIO**: Microphone access for sleep sound recording
|
||||
2. **POST_NOTIFICATIONS**: Android 13+ notification permission
|
||||
3. **Battery Optimization**: Prevent app from being killed during tracking
|
||||
|
||||
### Permission Request Flow
|
||||
|
||||
```kotlin
|
||||
// Request permissions sequentially
|
||||
when {
|
||||
!hasMicrophonePermission() -> requestMicrophone()
|
||||
!hasNotificationPermission() -> requestNotification()
|
||||
!isBatteryOptimizationIgnored() -> requestBatteryOptimization()
|
||||
else -> allPermissionsGranted()
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Request in sequence**: Request one permission at a time for better UX
|
||||
2. **Show rationale**: Explain why each permission is needed before requesting
|
||||
3. **Handle denial**: Provide fallback or guide users to settings
|
||||
4. **Check on resume**: Re-check permissions when app resumes
|
||||
|
||||
```kotlin
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!hasRequiredPermissions() && Asleep.isSleepTrackingAlive(applicationContext)) {
|
||||
handlePermissionLoss()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete PermissionManager implementation**, see `references/complete_viewmodel_implementation.md`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Distinguish between critical errors and warnings:
|
||||
|
||||
### Error Classification
|
||||
|
||||
```kotlin
|
||||
fun isWarning(errorCode: Int): Boolean {
|
||||
return errorCode in setOf(
|
||||
AsleepErrorCode.ERR_AUDIO_SILENCED,
|
||||
AsleepErrorCode.ERR_UPLOAD_FAILED
|
||||
)
|
||||
}
|
||||
|
||||
fun handleError(error: AsleepError) {
|
||||
if (isWarning(error.code)) {
|
||||
// Show warning, continue tracking
|
||||
Toast.makeText(context, getUserFriendlyMessage(error), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
// Critical error - stop tracking
|
||||
stopTracking()
|
||||
showErrorDialog(getUserFriendlyMessage(error))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI Patterns
|
||||
|
||||
### ViewBinding Example
|
||||
|
||||
```kotlin
|
||||
class TrackingFragment : Fragment() {
|
||||
private var _binding: FragmentTrackingBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: SleepTrackingViewModel by viewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.trackingState.collect { state ->
|
||||
when (state) {
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
binding.btnTrack.text = "Stop Tracking"
|
||||
}
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
binding.btnTrack.text = "Start Tracking"
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Jetpack Compose Example
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SleepTrackingScreen(
|
||||
viewModel: SleepTrackingViewModel = hiltViewModel()
|
||||
) {
|
||||
val trackingState by viewModel.trackingState.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
when (trackingState) {
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
Button(onClick = { viewModel.stopTracking() }) {
|
||||
Text("Stop Tracking")
|
||||
}
|
||||
}
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
Button(onClick = { viewModel.startTracking() }) {
|
||||
Text("Start Sleep Tracking")
|
||||
}
|
||||
}
|
||||
is AsleepState.STATE_ERROR -> {
|
||||
ErrorDisplay(error = (trackingState as AsleepState.STATE_ERROR).errorCode)
|
||||
}
|
||||
else -> {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete UI implementations with Material3 components**, see `references/ui_implementation_guide.md`.
|
||||
|
||||
## Lifecycle Management
|
||||
|
||||
### Handle App Lifecycle Events
|
||||
|
||||
```kotlin
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Reconnect to existing tracking session
|
||||
if (Asleep.isSleepTrackingAlive(applicationContext)) {
|
||||
viewModel.reconnectToTracking()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// Tracking continues in background via foreground service
|
||||
// No action needed
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Do NOT call endSleepTracking() here
|
||||
// Service continues in background
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Foreground Service
|
||||
|
||||
The Asleep SDK automatically manages a foreground service during tracking. The notification keeps the service alive:
|
||||
|
||||
```kotlin
|
||||
Asleep.beginSleepTracking(
|
||||
asleepConfig = config,
|
||||
asleepTrackingListener = listener,
|
||||
notificationTitle = "Sleep Tracking Active",
|
||||
notificationText = "Tracking your sleep patterns",
|
||||
notificationIcon = R.drawable.ic_sleep,
|
||||
notificationClass = MainActivity::class.java // Tapping notification opens this
|
||||
)
|
||||
```
|
||||
|
||||
The service will:
|
||||
- Keep the app alive during sleep tracking
|
||||
- Show persistent notification
|
||||
- Maintain microphone access
|
||||
- Continue even if user swipes away the app
|
||||
|
||||
## Real-Time Sleep Data
|
||||
|
||||
Access preliminary sleep data during tracking:
|
||||
|
||||
```kotlin
|
||||
// In ViewModel
|
||||
override fun onPerform(sequence: Int) {
|
||||
// Check after sequence 10, then every 10 sequences
|
||||
if (sequence > 10 && sequence % 10 == 0) {
|
||||
getCurrentSleepData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentSleepData() {
|
||||
Asleep.getCurrentSleepData(
|
||||
asleepSleepDataListener = object : Asleep.AsleepSleepDataListener {
|
||||
override fun onSleepDataReceived(session: Session) {
|
||||
val currentSleepStage = session.sleepStages?.lastOrNull()
|
||||
val currentSnoringStage = session.snoringStages?.lastOrNull()
|
||||
|
||||
Log.d("Sleep", "Current stage: $currentSleepStage")
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
Log.e("Sleep", "Failed to get current data: $errorCode")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Real-time data is preliminary and may differ from final report after processing.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing
|
||||
```kotlin
|
||||
class SleepTrackingViewModelTest {
|
||||
@get:Rule val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
@Test
|
||||
fun `startTracking should fail if not initialized`() = runTest {
|
||||
viewModel.startTracking()
|
||||
assertNotEquals(AsleepState.STATE_TRACKING_STARTED, viewModel.trackingState.value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
```kotlin
|
||||
@Test
|
||||
fun trackingFlow_complete() {
|
||||
onView(withId(R.id.btn_start_stop)).perform(click())
|
||||
onView(withId(R.id.tracking_indicator)).check(matches(isDisplayed()))
|
||||
}
|
||||
```
|
||||
|
||||
**For complete testing guide with Compose UI tests and test utilities**, see `references/testing_guide.md`.
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Tracking stops unexpectedly
|
||||
**Causes**: Battery optimization, notification dismissed, permission revoked, microphone conflict
|
||||
|
||||
**Solution**: Check battery optimization and permissions on resume:
|
||||
```kotlin
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!hasRequiredPermissions() && Asleep.isSleepTrackingAlive(applicationContext)) {
|
||||
handlePermissionLoss()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### No real-time data available
|
||||
**Cause**: Checking before sequence 10
|
||||
|
||||
**Solution**: Only call `getCurrentSleepData()` after sequence 10
|
||||
|
||||
### ERR_UPLOAD_FORBIDDEN error
|
||||
**Cause**: Same user_id tracking on multiple devices
|
||||
|
||||
**Solution**: Use unique user IDs per device or check for active sessions before starting
|
||||
|
||||
## Resources
|
||||
|
||||
This skill includes detailed reference documentation:
|
||||
|
||||
- `references/complete_viewmodel_implementation.md`: Complete ViewModel, Activity, and PermissionManager implementations
|
||||
- `references/ui_implementation_guide.md`: Complete ViewBinding and Jetpack Compose UI examples
|
||||
- `references/testing_guide.md`: Comprehensive unit, integration, and UI testing guides
|
||||
- `references/android_architecture_patterns.md`: Complete architecture examples from the official sample app
|
||||
- `references/gradle_setup.md`: Comprehensive Gradle configuration including dependencies and ProGuard rules
|
||||
|
||||
### Official Documentation
|
||||
|
||||
- **Android Getting Started**: https://docs-en.asleep.ai/docs/android-get-started.md
|
||||
- **AsleepConfig Reference**: https://docs-en.asleep.ai/docs/android-asleep-config.md
|
||||
- **SleepTrackingManager**: https://docs-en.asleep.ai/docs/android-sleep-tracking-manager.md
|
||||
- **Error Codes**: https://docs-en.asleep.ai/docs/android-error-codes.md
|
||||
|
||||
### Android Resources
|
||||
|
||||
- **Android Permissions**: https://developer.android.com/guide/topics/permissions/overview
|
||||
- **Foreground Services**: https://developer.android.com/develop/background-work/services/foreground-services
|
||||
- **StateFlow Guide**: https://developer.android.com/kotlin/flow/stateflow-and-sharedflow
|
||||
- **Hilt Documentation**: https://developer.android.com/training/dependency-injection/hilt-android
|
||||
|
||||
## Next Steps
|
||||
|
||||
After implementing Android sleep tracking:
|
||||
|
||||
1. **Test thoroughly**: Test on different Android versions (especially 13+ for notifications)
|
||||
2. **Handle edge cases**: Low battery, airplane mode, app updates during tracking
|
||||
3. **Fetch reports**: Use REST API or backend integration to retrieve sleep reports
|
||||
4. **Build UI**: Create compelling visualizations of sleep data
|
||||
5. **Analytics**: Track user engagement and sleep patterns
|
||||
|
||||
For backend report fetching and webhook integration, use the `sleeptrack-be` skill.
|
||||
@@ -0,0 +1,364 @@
|
||||
# Android Architecture Patterns for Asleep SDK
|
||||
|
||||
This document contains key architecture patterns from the Asleep Android sample app.
|
||||
|
||||
## 1. Application Setup with Hilt
|
||||
|
||||
```kotlin
|
||||
@HiltAndroidApp
|
||||
class SampleApplication : Application() {
|
||||
companion object {
|
||||
private lateinit var instance: SampleApplication
|
||||
val ACTION_AUTO_TRACKING: String by lazy {
|
||||
instance.packageName + ".ACTION_AUTO_TRACKING"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. State Management with Sealed Classes
|
||||
|
||||
```kotlin
|
||||
sealed class AsleepState {
|
||||
data object STATE_IDLE: AsleepState()
|
||||
data object STATE_INITIALIZING : AsleepState()
|
||||
data object STATE_INITIALIZED : AsleepState()
|
||||
data object STATE_TRACKING_STARTING : AsleepState()
|
||||
data object STATE_TRACKING_STARTED : AsleepState()
|
||||
data object STATE_TRACKING_STOPPING : AsleepState()
|
||||
data class STATE_ERROR(val errorCode: AsleepError) : AsleepState()
|
||||
}
|
||||
```
|
||||
|
||||
## 3. ViewModel with Asleep Integration
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class AsleepViewModel @Inject constructor(
|
||||
@ApplicationContext private val applicationContext: Application
|
||||
) : ViewModel() {
|
||||
|
||||
// State management
|
||||
private var _asleepState = MutableStateFlow<AsleepState>(AsleepState.STATE_IDLE)
|
||||
val asleepState: StateFlow<AsleepState> get() = _asleepState
|
||||
|
||||
// User and session data
|
||||
private var _asleepUserId = MutableLiveData<String?>(null)
|
||||
val asleepUserId: LiveData<String?> get() = _asleepUserId
|
||||
|
||||
private var _sessionId = MutableStateFlow<String?>(null)
|
||||
val sessionId: StateFlow<String?> get() = _sessionId
|
||||
|
||||
// Initialize SDK
|
||||
fun initAsleepConfig() {
|
||||
if (_asleepState.value != AsleepState.STATE_IDLE) return
|
||||
|
||||
_asleepState.value = AsleepState.STATE_INITIALIZING
|
||||
val storedUserId = PreferenceHelper.getAsleepUserId(applicationContext)
|
||||
|
||||
Asleep.initAsleepConfig(
|
||||
context = applicationContext,
|
||||
apiKey = Constants.ASLEEP_API_KEY,
|
||||
userId = storedUserId,
|
||||
baseUrl = Constants.BASE_URL,
|
||||
callbackUrl = Constants.CALLBACK_URL,
|
||||
service = Constants.SERVICE_NAME,
|
||||
asleepConfigListener = object : Asleep.AsleepConfigListener {
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
_asleepErrorCode.value = AsleepError(errorCode, detail)
|
||||
_asleepState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail))
|
||||
}
|
||||
|
||||
override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) {
|
||||
_asleepConfig.value = asleepConfig
|
||||
_asleepUserId.value = userId
|
||||
userId?.let { PreferenceHelper.putAsleepUserId(applicationContext, it) }
|
||||
_asleepState.value = AsleepState.STATE_INITIALIZED
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Tracking lifecycle
|
||||
private val asleepTrackingListener = object: Asleep.AsleepTrackingListener {
|
||||
override fun onStart(sessionId: String) {
|
||||
_asleepState.value = AsleepState.STATE_TRACKING_STARTED
|
||||
}
|
||||
|
||||
override fun onPerform(sequence: Int) {
|
||||
_sequence.postValue(sequence)
|
||||
if (sequence > 10 && (sequence % 10 == 1 || sequence - (_analyzedSeq ?: 0) > 10)) {
|
||||
getCurrentSleepData(sequence)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish(sessionId: String?) {
|
||||
_sessionId.value = sessionId
|
||||
if (asleepState.value is AsleepState.STATE_ERROR) {
|
||||
// Exit due to Error
|
||||
} else {
|
||||
// Successful Finish
|
||||
_asleepState.value = AsleepState.STATE_IDLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
handleErrorOrWarning(AsleepError(errorCode, detail))
|
||||
}
|
||||
}
|
||||
|
||||
fun beginSleepTracking() {
|
||||
if (_asleepState.value == AsleepState.STATE_INITIALIZED) {
|
||||
_asleepState.value = AsleepState.STATE_TRACKING_STARTING
|
||||
_asleepConfig.value?.let {
|
||||
Asleep.beginSleepTracking(
|
||||
asleepConfig = it,
|
||||
asleepTrackingListener = asleepTrackingListener,
|
||||
notificationTitle = applicationContext.getString(R.string.app_name),
|
||||
notificationText = "",
|
||||
notificationIcon = R.mipmap.ic_app,
|
||||
notificationClass = MainActivity::class.java
|
||||
)
|
||||
}
|
||||
PreferenceHelper.saveStartTrackingTime(applicationContext, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
fun endSleepTracking() {
|
||||
if (Asleep.isSleepTrackingAlive(applicationContext)) {
|
||||
_asleepState.value = AsleepState.STATE_TRACKING_STOPPING
|
||||
Asleep.endSleepTracking()
|
||||
}
|
||||
}
|
||||
|
||||
fun connectSleepTracking() {
|
||||
Asleep.connectSleepTracking(asleepTrackingListener)
|
||||
_asleepUserId.value = PreferenceHelper.getAsleepUserId(applicationContext)
|
||||
_asleepState.value = AsleepState.STATE_TRACKING_STARTED
|
||||
}
|
||||
|
||||
// Error handling
|
||||
fun handleErrorOrWarning(asleepError: AsleepError) {
|
||||
val code = asleepError.code
|
||||
val message = asleepError.message
|
||||
if (isWarning(code)) {
|
||||
// Log warning, continue tracking
|
||||
_warningMessage.postValue("$existingMessage\n${getCurrentTime()} $code - $message")
|
||||
} else {
|
||||
// Critical error, stop tracking
|
||||
_asleepErrorCode.postValue(asleepError)
|
||||
_asleepState.value = AsleepState.STATE_ERROR(asleepError)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Activity State Handling
|
||||
|
||||
```kotlin
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var permissionManager: PermissionManager
|
||||
private val asleepViewModel: AsleepViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
permissionManager = PermissionManager(this)
|
||||
setPermissionObserver()
|
||||
permissionManager.checkAllPermissions()
|
||||
|
||||
// State management with coroutines
|
||||
lifecycleScope.launch {
|
||||
asleepViewModel.asleepState.collect { state ->
|
||||
when (state) {
|
||||
AsleepState.STATE_IDLE -> {
|
||||
checkRunningService()
|
||||
}
|
||||
AsleepState.STATE_INITIALIZING -> {
|
||||
binding.btnControlTracking.text = "No user id"
|
||||
binding.btnControlTracking.isEnabled = false
|
||||
}
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
binding.btnControlTracking.apply {
|
||||
isEnabled = true
|
||||
text = getString(R.string.button_text_start_tracking)
|
||||
setOnClickListener {
|
||||
if (permissionManager.allPermissionsGranted.value == true) {
|
||||
asleepViewModel.beginSleepTracking()
|
||||
} else {
|
||||
permissionManager.checkAndRequestPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
binding.btnControlTracking.apply {
|
||||
isEnabled = true
|
||||
text = getString(R.string.button_text_stop_tracking)
|
||||
setOnClickListener {
|
||||
if (asleepViewModel.isEnoughTrackingTime()) {
|
||||
asleepViewModel.endSleepTracking()
|
||||
} else {
|
||||
showInsufficientTimeDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is AsleepState.STATE_ERROR -> {
|
||||
binding.btnControlTracking.isEnabled = false
|
||||
showErrorDialog(supportFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkRunningService() {
|
||||
val isRunningService = Asleep.isSleepTrackingAlive(applicationContext)
|
||||
if (isRunningService) {
|
||||
asleepViewModel.connectSleepTracking()
|
||||
} else {
|
||||
asleepViewModel.initAsleepConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Permission Management
|
||||
|
||||
```kotlin
|
||||
class PermissionManager(private val activity: AppCompatActivity) {
|
||||
private val context = activity.applicationContext
|
||||
|
||||
private val batteryOptimizationLauncher =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
checkAndRequestPermissions()
|
||||
}
|
||||
|
||||
private val micPermissionLauncher =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
checkAndRequestPermissions()
|
||||
}
|
||||
|
||||
private val notificationPermissionLauncher =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
checkAndRequestPermissions()
|
||||
}
|
||||
|
||||
private val _allPermissionsGranted = MutableLiveData(false)
|
||||
val allPermissionsGranted: LiveData<Boolean> = _allPermissionsGranted
|
||||
|
||||
fun checkAndRequestPermissions() {
|
||||
when {
|
||||
!isBatteryOptimizationIgnored() -> {
|
||||
val intent = Intent().apply {
|
||||
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
batteryOptimizationLauncher.launch(intent)
|
||||
}
|
||||
|
||||
context.checkSelfPermission(android.Manifest.permission.RECORD_AUDIO)
|
||||
!= android.content.pm.PackageManager.PERMISSION_GRANTED -> {
|
||||
if (shouldShowRequestPermissionRationale(activity,
|
||||
android.Manifest.permission.RECORD_AUDIO)) {
|
||||
showPermissionDialog()
|
||||
} else {
|
||||
micPermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
hasNotificationPermission().not() -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
shouldShowRequestPermissionRationale(activity,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS)) {
|
||||
showPermissionDialog()
|
||||
} else {
|
||||
notificationPermissionLauncher.launch(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
checkAllPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkAllPermissions() {
|
||||
_batteryOptimized.value = isBatteryOptimizationIgnored()
|
||||
_micPermission.value = context.checkSelfPermission(
|
||||
android.Manifest.permission.RECORD_AUDIO
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
_notificationPermission.value = hasNotificationPermission()
|
||||
|
||||
_allPermissionsGranted.value =
|
||||
(_batteryOptimized.value == true) &&
|
||||
(_micPermission.value == true) &&
|
||||
(_notificationPermission.value == true)
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationIgnored(): Boolean {
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val packageName = context.packageName
|
||||
return powerManager.isIgnoringBatteryOptimizations(packageName)
|
||||
}
|
||||
|
||||
private fun hasNotificationPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Error Handling Utilities
|
||||
|
||||
```kotlin
|
||||
data class AsleepError(val code: Int, val message: String)
|
||||
|
||||
internal fun isWarning(errorCode: Int): Boolean {
|
||||
return errorCode in setOf(
|
||||
AsleepErrorCode.ERR_AUDIO_SILENCED,
|
||||
AsleepErrorCode.ERR_AUDIO_UNSILENCED,
|
||||
AsleepErrorCode.ERR_UPLOAD_FAILED,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun getDebugMessage(errorCode: AsleepError): String {
|
||||
if (isNetworkError(errorCode.message)) {
|
||||
return "Please check your network connection."
|
||||
}
|
||||
if (isMethodFormatInvalid(errorCode.message)) {
|
||||
return "Please check the method format, including argument values and types."
|
||||
}
|
||||
|
||||
return when (errorCode.code) {
|
||||
AsleepErrorCode.ERR_MIC_PERMISSION ->
|
||||
"The app does not have microphone access permission."
|
||||
AsleepErrorCode.ERR_AUDIO ->
|
||||
"Another app is using the microphone, or there is an issue with the microphone settings."
|
||||
AsleepErrorCode.ERR_INVALID_URL ->
|
||||
"Please check the URL format."
|
||||
AsleepErrorCode.ERR_COMMON_EXPIRED ->
|
||||
"The API rate limit has been exceeded, or the plan has expired."
|
||||
AsleepErrorCode.ERR_UPLOAD_FORBIDDEN ->
|
||||
"initAsleepConfig() was performed elsewhere with the same ID during the tracking."
|
||||
AsleepErrorCode.ERR_UPLOAD_NOT_FOUND, AsleepErrorCode.ERR_CLOSE_NOT_FOUND ->
|
||||
"The session has already ended."
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,540 @@
|
||||
# Complete ViewModel and Activity Implementation
|
||||
|
||||
This reference provides complete, production-ready implementations for Android MVVM architecture with the Asleep SDK.
|
||||
|
||||
## Complete ViewModel with State Management
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class SleepTrackingViewModel @Inject constructor(
|
||||
@ApplicationContext private val applicationContext: Application
|
||||
) : ViewModel() {
|
||||
|
||||
// State management
|
||||
private val _trackingState = MutableStateFlow<AsleepState>(AsleepState.STATE_IDLE)
|
||||
val trackingState: StateFlow<AsleepState> = _trackingState.asStateFlow()
|
||||
|
||||
private val _userId = MutableLiveData<String?>()
|
||||
val userId: LiveData<String?> = _userId
|
||||
|
||||
private val _sessionId = MutableStateFlow<String?>(null)
|
||||
val sessionId: StateFlow<String?> = _sessionId.asStateFlow()
|
||||
|
||||
private val _sequence = MutableLiveData<Int>()
|
||||
val sequence: LiveData<Int> = _sequence
|
||||
|
||||
private var asleepConfig: AsleepConfig? = null
|
||||
|
||||
// Tracking listener
|
||||
private val trackingListener = object : Asleep.AsleepTrackingListener {
|
||||
override fun onStart(sessionId: String) {
|
||||
_sessionId.value = sessionId
|
||||
_trackingState.value = AsleepState.STATE_TRACKING_STARTED
|
||||
}
|
||||
|
||||
override fun onPerform(sequence: Int) {
|
||||
_sequence.postValue(sequence)
|
||||
|
||||
// Check real-time data every 10 sequences after sequence 10
|
||||
if (sequence > 10 && sequence % 10 == 0) {
|
||||
getCurrentSleepData()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish(sessionId: String?) {
|
||||
_sessionId.value = sessionId
|
||||
_trackingState.value = AsleepState.STATE_IDLE
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
handleError(AsleepError(errorCode, detail))
|
||||
}
|
||||
}
|
||||
|
||||
fun initializeSDK(userId: String) {
|
||||
if (_trackingState.value != AsleepState.STATE_IDLE) return
|
||||
|
||||
_trackingState.value = AsleepState.STATE_INITIALIZING
|
||||
|
||||
Asleep.initAsleepConfig(
|
||||
context = applicationContext,
|
||||
apiKey = BuildConfig.ASLEEP_API_KEY,
|
||||
userId = userId,
|
||||
baseUrl = "https://api.asleep.ai",
|
||||
callbackUrl = null,
|
||||
service = applicationContext.getString(R.string.app_name),
|
||||
asleepConfigListener = object : Asleep.AsleepConfigListener {
|
||||
override fun onSuccess(userId: String?, config: AsleepConfig?) {
|
||||
asleepConfig = config
|
||||
_userId.value = userId
|
||||
_trackingState.value = AsleepState.STATE_INITIALIZED
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
_trackingState.value = AsleepState.STATE_ERROR(
|
||||
AsleepError(errorCode, detail)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun startTracking() {
|
||||
val config = asleepConfig ?: return
|
||||
if (_trackingState.value != AsleepState.STATE_INITIALIZED) return
|
||||
|
||||
_trackingState.value = AsleepState.STATE_TRACKING_STARTING
|
||||
|
||||
Asleep.beginSleepTracking(
|
||||
asleepConfig = config,
|
||||
asleepTrackingListener = trackingListener,
|
||||
notificationTitle = "Sleep Tracking",
|
||||
notificationText = "Recording your sleep",
|
||||
notificationIcon = R.drawable.ic_notification,
|
||||
notificationClass = MainActivity::class.java
|
||||
)
|
||||
}
|
||||
|
||||
fun stopTracking() {
|
||||
if (Asleep.isSleepTrackingAlive(applicationContext)) {
|
||||
_trackingState.value = AsleepState.STATE_TRACKING_STOPPING
|
||||
Asleep.endSleepTracking()
|
||||
}
|
||||
}
|
||||
|
||||
fun reconnectToTracking() {
|
||||
if (Asleep.isSleepTrackingAlive(applicationContext)) {
|
||||
Asleep.connectSleepTracking(trackingListener)
|
||||
_trackingState.value = AsleepState.STATE_TRACKING_STARTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentSleepData() {
|
||||
Asleep.getCurrentSleepData(
|
||||
asleepSleepDataListener = object : Asleep.AsleepSleepDataListener {
|
||||
override fun onSleepDataReceived(session: Session) {
|
||||
// Process real-time sleep data
|
||||
val currentStage = session.sleepStages?.lastOrNull()
|
||||
Log.d("Sleep", "Current stage: $currentStage")
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
Log.e("Sleep", "Failed to get data: $errorCode")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleError(error: AsleepError) {
|
||||
if (isWarning(error.code)) {
|
||||
// Log warning but continue tracking
|
||||
Log.w("Sleep", "Warning: ${error.code} - ${error.message}")
|
||||
} else {
|
||||
// Critical error - stop tracking
|
||||
_trackingState.value = AsleepState.STATE_ERROR(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Activity Implementation
|
||||
|
||||
```kotlin
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val viewModel: SleepTrackingViewModel by viewModels()
|
||||
private lateinit var permissionManager: PermissionManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
permissionManager = PermissionManager(this)
|
||||
|
||||
setupUI()
|
||||
observeState()
|
||||
checkExistingSession()
|
||||
}
|
||||
|
||||
private fun setupUI() {
|
||||
binding.btnStartStop.setOnClickListener {
|
||||
when (viewModel.trackingState.value) {
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
if (permissionManager.allPermissionsGranted.value == true) {
|
||||
viewModel.startTracking()
|
||||
} else {
|
||||
permissionManager.requestPermissions()
|
||||
}
|
||||
}
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
viewModel.stopTracking()
|
||||
}
|
||||
else -> {
|
||||
// Button disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeState() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.trackingState.collect { state ->
|
||||
updateUI(state)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.sequence.observe(this) { sequence ->
|
||||
binding.tvSequence.text = "Sequence: $sequence"
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(state: AsleepState) {
|
||||
when (state) {
|
||||
AsleepState.STATE_IDLE -> {
|
||||
binding.btnStartStop.isEnabled = false
|
||||
binding.btnStartStop.text = "Initializing..."
|
||||
}
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
binding.btnStartStop.isEnabled = true
|
||||
binding.btnStartStop.text = "Start Tracking"
|
||||
}
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
binding.btnStartStop.isEnabled = true
|
||||
binding.btnStartStop.text = "Stop Tracking"
|
||||
binding.trackingIndicator.visibility = View.VISIBLE
|
||||
}
|
||||
AsleepState.STATE_TRACKING_STOPPING -> {
|
||||
binding.btnStartStop.isEnabled = false
|
||||
binding.btnStartStop.text = "Stopping..."
|
||||
}
|
||||
is AsleepState.STATE_ERROR -> {
|
||||
showErrorDialog(state.errorCode)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkExistingSession() {
|
||||
if (Asleep.isSleepTrackingAlive(applicationContext)) {
|
||||
viewModel.reconnectToTracking()
|
||||
} else {
|
||||
viewModel.initializeSDK(getUserId())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUserId(): String {
|
||||
// Load from SharedPreferences or generate
|
||||
val prefs = getSharedPreferences("asleep", MODE_PRIVATE)
|
||||
return prefs.getString("user_id", null) ?: run {
|
||||
val newId = UUID.randomUUID().toString()
|
||||
prefs.edit().putString("user_id", newId).apply()
|
||||
newId
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete PermissionManager
|
||||
|
||||
```kotlin
|
||||
class PermissionManager(private val activity: AppCompatActivity) {
|
||||
private val context = activity.applicationContext
|
||||
|
||||
// Permission launchers
|
||||
private val batteryOptimizationLauncher =
|
||||
activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
checkAndRequestNext()
|
||||
}
|
||||
|
||||
private val micPermissionLauncher =
|
||||
activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) checkAndRequestNext()
|
||||
else showPermissionDeniedDialog("Microphone")
|
||||
}
|
||||
|
||||
private val notificationPermissionLauncher =
|
||||
activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) checkAndRequestNext()
|
||||
else showPermissionDeniedDialog("Notifications")
|
||||
}
|
||||
|
||||
private val _allPermissionsGranted = MutableLiveData(false)
|
||||
val allPermissionsGranted: LiveData<Boolean> = _allPermissionsGranted
|
||||
|
||||
fun requestPermissions() {
|
||||
checkAndRequestNext()
|
||||
}
|
||||
|
||||
private fun checkAndRequestNext() {
|
||||
when {
|
||||
!isBatteryOptimizationIgnored() -> requestBatteryOptimization()
|
||||
!hasMicrophonePermission() -> requestMicrophone()
|
||||
!hasNotificationPermission() -> requestNotification()
|
||||
else -> {
|
||||
_allPermissionsGranted.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBatteryOptimization() {
|
||||
val intent = Intent().apply {
|
||||
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
batteryOptimizationLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun requestMicrophone() {
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
activity,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
)) {
|
||||
showPermissionRationale(
|
||||
"Microphone access is required to record sleep sounds"
|
||||
)
|
||||
} else {
|
||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotification() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
} else {
|
||||
checkAndRequestNext()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationIgnored(): Boolean {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return pm.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}
|
||||
|
||||
private fun hasMicrophonePermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun hasNotificationPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPermissionRationale(message: String) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission Required")
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK") { _, _ ->
|
||||
checkAndRequestNext()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showPermissionDeniedDialog(permissionName: String) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission Denied")
|
||||
.setMessage("$permissionName permission is required for sleep tracking. Please grant it in app settings.")
|
||||
.setPositiveButton("Settings") { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Minimal Production Templates
|
||||
|
||||
### Minimal ViewModel
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class SleepTrackingViewModel @Inject constructor(
|
||||
@ApplicationContext private val app: Application
|
||||
) : ViewModel() {
|
||||
|
||||
private val _trackingState = MutableStateFlow<AsleepState>(AsleepState.STATE_IDLE)
|
||||
val trackingState: StateFlow<AsleepState> = _trackingState
|
||||
|
||||
private var config: AsleepConfig? = null
|
||||
|
||||
private val listener = object : Asleep.AsleepTrackingListener {
|
||||
override fun onStart(sessionId: String) {
|
||||
_trackingState.value = AsleepState.STATE_TRACKING_STARTED
|
||||
}
|
||||
override fun onPerform(sequence: Int) {}
|
||||
override fun onFinish(sessionId: String?) {
|
||||
_trackingState.value = AsleepState.STATE_IDLE
|
||||
}
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
_trackingState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail))
|
||||
}
|
||||
}
|
||||
|
||||
fun initializeSDK(userId: String) {
|
||||
Asleep.initAsleepConfig(
|
||||
context = app,
|
||||
apiKey = BuildConfig.ASLEEP_API_KEY,
|
||||
userId = userId,
|
||||
baseUrl = "https://api.asleep.ai",
|
||||
callbackUrl = null,
|
||||
service = "MyApp",
|
||||
asleepConfigListener = object : Asleep.AsleepConfigListener {
|
||||
override fun onSuccess(userId: String?, asleepConfig: AsleepConfig?) {
|
||||
config = asleepConfig
|
||||
_trackingState.value = AsleepState.STATE_INITIALIZED
|
||||
}
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
_trackingState.value = AsleepState.STATE_ERROR(AsleepError(errorCode, detail))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun startTracking() {
|
||||
config?.let {
|
||||
Asleep.beginSleepTracking(
|
||||
asleepConfig = it,
|
||||
asleepTrackingListener = listener,
|
||||
notificationTitle = "Sleep Tracking",
|
||||
notificationText = "Active",
|
||||
notificationIcon = R.drawable.ic_notification,
|
||||
notificationClass = SleepActivity::class.java
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopTracking() {
|
||||
Asleep.endSleepTracking()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Activity
|
||||
|
||||
```kotlin
|
||||
@AndroidEntryPoint
|
||||
class SleepActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivitySleepBinding
|
||||
private val viewModel: SleepTrackingViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivitySleepBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// Initialize SDK
|
||||
viewModel.initializeSDK(loadUserId())
|
||||
|
||||
// Setup button
|
||||
binding.btnTrack.setOnClickListener {
|
||||
when (viewModel.trackingState.value) {
|
||||
AsleepState.STATE_INITIALIZED -> viewModel.startTracking()
|
||||
AsleepState.STATE_TRACKING_STARTED -> viewModel.stopTracking()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe state
|
||||
lifecycleScope.launch {
|
||||
viewModel.trackingState.collect { state ->
|
||||
binding.btnTrack.text = when (state) {
|
||||
AsleepState.STATE_INITIALIZED -> "Start"
|
||||
AsleepState.STATE_TRACKING_STARTED -> "Stop"
|
||||
else -> "Loading..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUserId(): String {
|
||||
val prefs = getSharedPreferences("app", MODE_PRIVATE)
|
||||
return prefs.getString("user_id", null) ?: UUID.randomUUID().toString().also {
|
||||
prefs.edit().putString("user_id", it).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Management Pattern
|
||||
|
||||
Define states using sealed classes:
|
||||
|
||||
```kotlin
|
||||
sealed class AsleepState {
|
||||
data object STATE_IDLE: AsleepState()
|
||||
data object STATE_INITIALIZING : AsleepState()
|
||||
data object STATE_INITIALIZED : AsleepState()
|
||||
data object STATE_TRACKING_STARTING : AsleepState()
|
||||
data object STATE_TRACKING_STARTED : AsleepState()
|
||||
data object STATE_TRACKING_STOPPING : AsleepState()
|
||||
data class STATE_ERROR(val errorCode: AsleepError) : AsleepState()
|
||||
}
|
||||
|
||||
data class AsleepError(val code: Int, val message: String)
|
||||
```
|
||||
|
||||
## Real-Time Data Access
|
||||
|
||||
```kotlin
|
||||
// In ViewModel
|
||||
private var lastCheckedSequence = 0
|
||||
|
||||
private val trackingListener = object : Asleep.AsleepTrackingListener {
|
||||
override fun onPerform(sequence: Int) {
|
||||
_sequence.postValue(sequence)
|
||||
|
||||
// Check after sequence 10, then every 10 sequences
|
||||
if (sequence > 10 && sequence - lastCheckedSequence >= 10) {
|
||||
getCurrentSleepData(sequence)
|
||||
}
|
||||
}
|
||||
// ... other callbacks
|
||||
}
|
||||
|
||||
private fun getCurrentSleepData(sequence: Int) {
|
||||
Asleep.getCurrentSleepData(
|
||||
asleepSleepDataListener = object : Asleep.AsleepSleepDataListener {
|
||||
override fun onSleepDataReceived(session: Session) {
|
||||
lastCheckedSequence = sequence
|
||||
|
||||
// Extract current data
|
||||
val currentSleepStage = session.sleepStages?.lastOrNull()
|
||||
val currentSnoringStage = session.snoringStages?.lastOrNull()
|
||||
|
||||
_currentSleepStage.postValue(currentSleepStage)
|
||||
|
||||
Log.d("Sleep", "Current stage: $currentSleepStage")
|
||||
Log.d("Sleep", "Snoring: $currentSnoringStage")
|
||||
}
|
||||
|
||||
override fun onFail(errorCode: Int, detail: String) {
|
||||
Log.e("Sleep", "Failed to get current data: $errorCode")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
146
skills/sleeptrack-android/references/gradle_setup.md
Normal file
146
skills/sleeptrack-android/references/gradle_setup.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Gradle Setup for Asleep SDK
|
||||
|
||||
## Project-level build.gradle
|
||||
|
||||
```gradle
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.48'
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.2' apply false
|
||||
id 'com.android.library' version '8.2.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
|
||||
id 'com.google.dagger.hilt.android' version '2.48' apply false
|
||||
}
|
||||
```
|
||||
|
||||
## App-level build.gradle
|
||||
|
||||
```gradle
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'kotlin-kapt'
|
||||
id 'dagger.hilt.android.plugin'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.example.yourapp'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.example.yourapp"
|
||||
minSdk 24 // Minimum SDK 24 required for Asleep SDK
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Store API key securely in local.properties
|
||||
Properties properties = new Properties()
|
||||
properties.load(project.rootProject.file('local.properties').newDataInputStream())
|
||||
buildConfigField "String", "ASLEEP_API_KEY", properties['asleep_api_key']
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Android dependencies
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
|
||||
// Hilt for dependency injection
|
||||
implementation "com.google.dagger:hilt-android:2.48"
|
||||
kapt "com.google.dagger:hilt-compiler:2.48"
|
||||
|
||||
// Activity and Fragment KTX
|
||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
|
||||
// Required for Asleep SDK
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||
debugImplementation 'com.squareup.okhttp3:logging-interceptor:4.10.0'
|
||||
implementation 'com.google.code.gson:gson:2.10'
|
||||
|
||||
// Asleep SDK (check for latest version)
|
||||
implementation 'ai.asleep:asleepsdk:3.1.4'
|
||||
|
||||
// Testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
}
|
||||
```
|
||||
|
||||
## local.properties
|
||||
|
||||
```properties
|
||||
# Store your Asleep API key securely (never commit this file to version control)
|
||||
asleep_api_key="your_api_key_here"
|
||||
```
|
||||
|
||||
## Key Requirements
|
||||
|
||||
1. **Minimum SDK**: 24 (Android 7.0)
|
||||
2. **Target SDK**: 34 recommended
|
||||
3. **Java Version**: 17
|
||||
4. **Kotlin Version**: 1.9.24 or higher
|
||||
5. **Gradle Plugin**: 8.2.2 or higher
|
||||
|
||||
## Dependency Notes
|
||||
|
||||
- **OkHttp**: Required for network operations
|
||||
- **Gson**: Required for JSON parsing
|
||||
- **Hilt**: Recommended for dependency injection (not strictly required)
|
||||
- **ViewBinding**: Recommended for type-safe view access
|
||||
|
||||
## ProGuard Rules
|
||||
|
||||
If using ProGuard/R8, add these rules to `proguard-rules.pro`:
|
||||
|
||||
```proguard
|
||||
# Asleep SDK
|
||||
-keep class ai.asleep.asleepsdk.** { *; }
|
||||
-dontwarn ai.asleep.asleepsdk.**
|
||||
|
||||
# Gson
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-dontwarn sun.misc.**
|
||||
-keep class * implements com.google.gson.TypeAdapter
|
||||
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||
-keep class * implements com.google.gson.JsonSerializer
|
||||
-keep class * implements com.google.gson.JsonDeserializer
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||
```
|
||||
523
skills/sleeptrack-android/references/testing_guide.md
Normal file
523
skills/sleeptrack-android/references/testing_guide.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# Testing Guide for Android Sleep Tracking
|
||||
|
||||
This guide provides comprehensive testing patterns for Android sleep tracking applications.
|
||||
|
||||
## Unit Testing
|
||||
|
||||
### ViewModel Testing Setup
|
||||
|
||||
```kotlin
|
||||
@ExperimentalCoroutinesApi
|
||||
class SleepTrackingViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val instantExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private lateinit var viewModel: SleepTrackingViewModel
|
||||
private lateinit var mockContext: Application
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockContext = mock(Application::class.java)
|
||||
viewModel = SleepTrackingViewModel(mockContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initializeSDK should transition to INITIALIZED on success`() = runTest {
|
||||
// Given
|
||||
val userId = "test_user_123"
|
||||
|
||||
// When
|
||||
viewModel.initializeSDK(userId)
|
||||
|
||||
// Simulate success callback
|
||||
// (requires mocking Asleep SDK)
|
||||
|
||||
// Then
|
||||
assertEquals(AsleepState.STATE_INITIALIZED, viewModel.trackingState.value)
|
||||
assertEquals(userId, viewModel.userId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startTracking should fail if not initialized`() = runTest {
|
||||
// When
|
||||
viewModel.startTracking()
|
||||
|
||||
// Then
|
||||
assertNotEquals(AsleepState.STATE_TRACKING_STARTED, viewModel.trackingState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopTracking should transition state correctly`() = runTest {
|
||||
// Given
|
||||
viewModel.initializeSDK("test_user")
|
||||
// Simulate initialization success
|
||||
viewModel.startTracking()
|
||||
// Simulate tracking started
|
||||
|
||||
// When
|
||||
viewModel.stopTracking()
|
||||
|
||||
// Then
|
||||
assertEquals(AsleepState.STATE_TRACKING_STOPPING, viewModel.trackingState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error handling should classify warnings correctly`() = runTest {
|
||||
// Given
|
||||
val warningError = AsleepError(
|
||||
AsleepErrorCode.ERR_AUDIO_SILENCED,
|
||||
"Microphone temporarily unavailable"
|
||||
)
|
||||
|
||||
// When
|
||||
val isWarning = isWarning(warningError.code)
|
||||
|
||||
// Then
|
||||
assertTrue(isWarning)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MainDispatcherRule for Coroutines
|
||||
|
||||
```kotlin
|
||||
@ExperimentalCoroutinesApi
|
||||
class MainDispatcherRule(
|
||||
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
|
||||
) : TestWatcher() {
|
||||
|
||||
override fun starting(description: Description) {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
override fun finished(description: Description) {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing StateFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `trackingState should emit correct states during tracking lifecycle`() = runTest {
|
||||
// Given
|
||||
val states = mutableListOf<AsleepState>()
|
||||
val job = launch {
|
||||
viewModel.trackingState.collect { state ->
|
||||
states.add(state)
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
viewModel.initializeSDK("test_user")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(states.contains(AsleepState.STATE_INITIALIZING))
|
||||
assertTrue(states.contains(AsleepState.STATE_INITIALIZED))
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### Activity Testing
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SleepTrackingIntegrationTest {
|
||||
|
||||
@get:Rule
|
||||
val activityRule = ActivityScenarioRule(MainActivity::class.java)
|
||||
|
||||
@Test
|
||||
fun trackingFlow_complete() {
|
||||
// Check permissions granted
|
||||
onView(withId(R.id.btn_start_stop))
|
||||
.check(matches(isEnabled()))
|
||||
|
||||
// Start tracking
|
||||
onView(withId(R.id.btn_start_stop))
|
||||
.perform(click())
|
||||
|
||||
// Verify tracking started
|
||||
onView(withId(R.id.tracking_indicator))
|
||||
.check(matches(isDisplayed()))
|
||||
|
||||
// Wait for sequences
|
||||
Thread.sleep(60000) // 1 minute
|
||||
|
||||
// Stop tracking
|
||||
onView(withId(R.id.btn_start_stop))
|
||||
.perform(click())
|
||||
|
||||
// Verify stopped
|
||||
onView(withId(R.id.tracking_indicator))
|
||||
.check(matches(not(isDisplayed())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorDisplay_showsCorrectMessage() {
|
||||
// Simulate error state
|
||||
// Verify error message displayed
|
||||
onView(withId(R.id.error_text))
|
||||
.check(matches(isDisplayed()))
|
||||
.check(matches(withText(containsString("Microphone permission"))))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fragment Testing
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TrackingFragmentTest {
|
||||
|
||||
@Test
|
||||
fun fragmentLaunch_displaysCorrectUI() {
|
||||
// Launch fragment in container
|
||||
launchFragmentInContainer<TrackingFragment>(
|
||||
themeResId = R.style.Theme_SleepTracking
|
||||
)
|
||||
|
||||
// Verify initial UI state
|
||||
onView(withId(R.id.btnTrack))
|
||||
.check(matches(isDisplayed()))
|
||||
.check(matches(withText("Start Tracking")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clickStartButton_requestsPermissions() {
|
||||
launchFragmentInContainer<TrackingFragment>()
|
||||
|
||||
// Click start button
|
||||
onView(withId(R.id.btnTrack))
|
||||
.perform(click())
|
||||
|
||||
// Verify permission dialog or state change
|
||||
// This depends on permission state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Compose UI Testing
|
||||
|
||||
### Basic Compose Testing
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SleepTrackingScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun initialState_showsStartButton() {
|
||||
// Given
|
||||
val viewModel = mockViewModel(AsleepState.STATE_INITIALIZED)
|
||||
|
||||
// When
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Start Sleep Tracking")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackingActive_showsStopButton() {
|
||||
// Given
|
||||
val viewModel = mockViewModel(AsleepState.STATE_TRACKING_STARTED)
|
||||
|
||||
// When
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Stop Tracking")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Tracking in progress")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorState_displaysErrorMessage() {
|
||||
// Given
|
||||
val error = AsleepError(AsleepErrorCode.ERR_MIC_PERMISSION, "Permission denied")
|
||||
val viewModel = mockViewModel(AsleepState.STATE_ERROR(error))
|
||||
|
||||
// When
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Then
|
||||
composeTestRule.onNodeWithText("Microphone permission is required")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun mockViewModel(state: AsleepState): SleepTrackingViewModel {
|
||||
return mock(SleepTrackingViewModel::class.java).apply {
|
||||
whenever(trackingState).thenReturn(MutableStateFlow(state))
|
||||
whenever(sequence).thenReturn(MutableLiveData(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing User Interactions
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun clickStartButton_startsTracking() {
|
||||
// Given
|
||||
val viewModel = SleepTrackingViewModel(mockContext)
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithText("Start Sleep Tracking")
|
||||
.performClick()
|
||||
|
||||
// Then
|
||||
verify(viewModel).startTracking()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clickStopButton_stopsTracking() {
|
||||
// Given
|
||||
val viewModel = mockViewModel(AsleepState.STATE_TRACKING_STARTED)
|
||||
composeTestRule.setContent {
|
||||
SleepTrackingScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// When
|
||||
composeTestRule.onNodeWithText("Stop Tracking")
|
||||
.performClick()
|
||||
|
||||
// Then
|
||||
verify(viewModel).stopTracking()
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Testing
|
||||
|
||||
### Testing Permission Manager
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PermissionManagerTest {
|
||||
|
||||
private lateinit var activity: AppCompatActivity
|
||||
private lateinit var permissionManager: PermissionManager
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val scenario = ActivityScenario.launch(TestActivity::class.java)
|
||||
scenario.onActivity { act ->
|
||||
activity = act
|
||||
permissionManager = PermissionManager(activity)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestPermissions_checksAllPermissions() {
|
||||
// When
|
||||
permissionManager.requestPermissions()
|
||||
|
||||
// Then
|
||||
// Verify permission checks called
|
||||
verify(permissionManager).isBatteryOptimizationIgnored()
|
||||
verify(permissionManager).hasMicrophonePermission()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allPermissionsGranted_updatesLiveData() {
|
||||
// Given
|
||||
// Mock all permissions as granted
|
||||
|
||||
// When
|
||||
permissionManager.checkAndRequestNext()
|
||||
|
||||
// Then
|
||||
assertTrue(permissionManager.allPermissionsGranted.value == true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Instrumentation Testing
|
||||
|
||||
### Testing Foreground Service
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ForegroundServiceTest {
|
||||
|
||||
@get:Rule
|
||||
val serviceRule = ServiceTestRule()
|
||||
|
||||
@Test
|
||||
fun trackingService_startsForeground() {
|
||||
// Given
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
// When
|
||||
val serviceIntent = Intent(context, SleepTrackingService::class.java)
|
||||
serviceRule.startService(serviceIntent)
|
||||
|
||||
// Then
|
||||
// Verify service is in foreground
|
||||
assertTrue(isServiceForeground(context, SleepTrackingService::class.java))
|
||||
}
|
||||
|
||||
private fun isServiceForeground(context: Context, serviceClass: Class<*>): Boolean {
|
||||
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
return manager.getRunningServices(Integer.MAX_VALUE)
|
||||
.any { it.service.className == serviceClass.name && it.foreground }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mock SDK Testing
|
||||
|
||||
### Creating SDK Mocks
|
||||
|
||||
```kotlin
|
||||
class MockAsleepSDK {
|
||||
companion object {
|
||||
fun mockInitSuccess(userId: String, listener: Asleep.AsleepConfigListener) {
|
||||
val config = mock(AsleepConfig::class.java)
|
||||
listener.onSuccess(userId, config)
|
||||
}
|
||||
|
||||
fun mockInitFail(errorCode: Int, listener: Asleep.AsleepConfigListener) {
|
||||
listener.onFail(errorCode, "Test error")
|
||||
}
|
||||
|
||||
fun mockTrackingStart(sessionId: String, listener: Asleep.AsleepTrackingListener) {
|
||||
listener.onStart(sessionId)
|
||||
}
|
||||
|
||||
fun mockTrackingPerform(sequence: Int, listener: Asleep.AsleepTrackingListener) {
|
||||
listener.onPerform(sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Mocks in Tests
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun initializeSDK_successFlow() = runTest {
|
||||
// Given
|
||||
val userId = "test_user"
|
||||
|
||||
// Mock Asleep SDK
|
||||
MockAsleepSDK.mockInitSuccess(userId, any())
|
||||
|
||||
// When
|
||||
viewModel.initializeSDK(userId)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(AsleepState.STATE_INITIALIZED, viewModel.trackingState.value)
|
||||
}
|
||||
```
|
||||
|
||||
## Test Dependencies
|
||||
|
||||
Add to your `build.gradle`:
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
// Unit testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.mockito:mockito-core:5.3.1'
|
||||
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.0.0'
|
||||
testImplementation 'androidx.arch.core:core-testing:2.2.0'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
|
||||
// Android instrumentation testing
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
|
||||
// Fragment testing
|
||||
debugImplementation 'androidx.fragment:fragment-testing:1.6.1'
|
||||
|
||||
// Compose testing
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.5.4'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest:1.5.4'
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
1. **Unit Tests**: Test ViewModels, business logic, and state management
|
||||
2. **Integration Tests**: Test UI interactions and component integration
|
||||
3. **Use Test Rules**: Leverage JUnit rules for setup/teardown
|
||||
4. **Mock External Dependencies**: Mock Asleep SDK calls for predictable tests
|
||||
5. **Test Coroutines**: Use `runTest` and `TestDispatcher` for coroutine testing
|
||||
6. **Test Permissions**: Verify permission flows but avoid actual permission dialogs
|
||||
7. **Compose Tests**: Use semantic properties and avoid hardcoded strings
|
||||
8. **CI/CD**: Run tests in continuous integration pipeline
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
// Enable debug logging for tests
|
||||
Log.setDebug(true)
|
||||
}
|
||||
```
|
||||
|
||||
### Capture Screenshots on Failure
|
||||
|
||||
```kotlin
|
||||
@Rule
|
||||
fun testRule = TestRule { base, description ->
|
||||
object : Statement() {
|
||||
override fun evaluate() {
|
||||
try {
|
||||
base.evaluate()
|
||||
} catch (t: Throwable) {
|
||||
// Capture screenshot
|
||||
Screenshot.capture()
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Aim for:
|
||||
- **Unit Tests**: 80%+ coverage of ViewModels and business logic
|
||||
- **Integration Tests**: Key user flows and state transitions
|
||||
- **UI Tests**: Critical user interactions
|
||||
|
||||
Use JaCoCo for coverage reporting:
|
||||
|
||||
```gradle
|
||||
apply plugin: 'jacoco'
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.10"
|
||||
}
|
||||
```
|
||||
524
skills/sleeptrack-android/references/ui_implementation_guide.md
Normal file
524
skills/sleeptrack-android/references/ui_implementation_guide.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# UI Implementation Guide
|
||||
|
||||
This guide provides complete UI implementations for Android sleep tracking with both ViewBinding and Jetpack Compose.
|
||||
|
||||
## ViewBinding Implementation
|
||||
|
||||
### Complete Fragment with ViewBinding
|
||||
|
||||
```kotlin
|
||||
class TrackingFragment : Fragment() {
|
||||
private var _binding: FragmentTrackingBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: SleepTrackingViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentTrackingBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.btnTrack.setOnClickListener {
|
||||
viewModel.startTracking()
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.trackingState.collect { state ->
|
||||
when (state) {
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
binding.progressIndicator.visibility = View.VISIBLE
|
||||
binding.btnTrack.text = "Stop Tracking"
|
||||
}
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
binding.progressIndicator.visibility = View.GONE
|
||||
binding.btnTrack.text = "Start Tracking"
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout XML (fragment_tracking.xml)
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/btnTrack" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSequence"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sequence: 0"
|
||||
android:textSize="18sp"
|
||||
app:layout_constraintTop_toBottomOf="@id/progressIndicator"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/btnTrack" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTrack"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Start Tracking"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
```
|
||||
|
||||
## Jetpack Compose Implementation
|
||||
|
||||
### Complete Tracking Screen
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SleepTrackingScreen(
|
||||
viewModel: SleepTrackingViewModel = hiltViewModel()
|
||||
) {
|
||||
val trackingState by viewModel.trackingState.collectAsState()
|
||||
val sequence by viewModel.sequence.observeAsState(0)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
when (trackingState) {
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("Tracking in progress")
|
||||
Text("Sequence: $sequence")
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(onClick = { viewModel.stopTracking() }) {
|
||||
Text("Stop Tracking")
|
||||
}
|
||||
}
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
Button(onClick = { viewModel.startTracking() }) {
|
||||
Text("Start Sleep Tracking")
|
||||
}
|
||||
}
|
||||
is AsleepState.STATE_ERROR -> {
|
||||
val error = (trackingState as AsleepState.STATE_ERROR).errorCode
|
||||
ErrorDisplay(error = error)
|
||||
}
|
||||
else -> {
|
||||
CircularProgressIndicator()
|
||||
Text("Initializing...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorDisplay(error: AsleepError) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = "Error",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = getUserFriendlyMessage(error),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Compose UI with Sleep Stages
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DetailedTrackingScreen(
|
||||
viewModel: SleepTrackingViewModel = hiltViewModel()
|
||||
) {
|
||||
val trackingState by viewModel.trackingState.collectAsState()
|
||||
val sequence by viewModel.sequence.observeAsState(0)
|
||||
val sessionId by viewModel.sessionId.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Sleep Tracking") }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
when (trackingState) {
|
||||
AsleepState.STATE_TRACKING_STARTED -> {
|
||||
TrackingActiveContent(
|
||||
sequence = sequence,
|
||||
sessionId = sessionId,
|
||||
onStop = { viewModel.stopTracking() }
|
||||
)
|
||||
}
|
||||
AsleepState.STATE_INITIALIZED -> {
|
||||
TrackingIdleContent(
|
||||
onStart = { viewModel.startTracking() }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LoadingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrackingActiveContent(
|
||||
sequence: Int,
|
||||
sessionId: String?,
|
||||
onStop: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Status card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Tracking Active",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Session: ${sessionId?.take(8)}...",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Progress indicator
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(120.dp),
|
||||
strokeWidth = 8.dp
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "$sequence",
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
Text(
|
||||
text = "sequences",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Duration estimate
|
||||
val minutes = sequence / 2
|
||||
Text(
|
||||
text = "Approximately $minutes minutes",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Stop button
|
||||
Button(
|
||||
onClick = onStop,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Stop Tracking")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrackingIdleContent(onStart: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.NightsStay,
|
||||
contentDescription = "Sleep",
|
||||
modifier = Modifier.size(120.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Ready to Track Sleep",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Make sure you're in a quiet environment",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = onStart,
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
) {
|
||||
Text("Start Tracking")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("Initializing SDK...")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Material Design 3 Theming
|
||||
|
||||
```kotlin
|
||||
// Theme setup for Compose
|
||||
@Composable
|
||||
fun SleepTrackingTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) {
|
||||
darkColorScheme(
|
||||
primary = Color(0xFF6200EE),
|
||||
secondary = Color(0xFF03DAC6),
|
||||
error = Color(0xFFCF6679)
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = Color(0xFF6200EE),
|
||||
secondary = Color(0xFF03DAC6),
|
||||
error = Color(0xFFB00020)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Views (XML)
|
||||
|
||||
### CircularProgressView
|
||||
|
||||
```kotlin
|
||||
class CircularProgressView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var progress = 0
|
||||
private val paint = Paint().apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 20f
|
||||
color = Color.BLUE
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
fun setProgress(value: Int) {
|
||||
progress = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val centerX = width / 2f
|
||||
val centerY = height / 2f
|
||||
val radius = (min(width, height) / 2f) - 20f
|
||||
|
||||
// Draw circle
|
||||
canvas.drawCircle(centerX, centerY, radius, paint)
|
||||
|
||||
// Draw progress arc
|
||||
val rectF = RectF(
|
||||
centerX - radius,
|
||||
centerY - radius,
|
||||
centerX + radius,
|
||||
centerY + radius
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
canvas.drawArc(rectF, -90f, (progress * 360f / 100f), true, paint)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission UI Patterns
|
||||
|
||||
### Permission Request Dialog (Compose)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun PermissionRequestDialog(
|
||||
permissionName: String,
|
||||
rationale: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Permission Required") },
|
||||
text = { Text(rationale) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text("Grant Permission")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Using with Accompanist Permissions
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun PermissionScreen(
|
||||
onPermissionsGranted: () -> Unit
|
||||
) {
|
||||
val micPermissionState = rememberPermissionState(
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
|
||||
when {
|
||||
micPermissionState.status.isGranted -> {
|
||||
onPermissionsGranted()
|
||||
}
|
||||
micPermissionState.status.shouldShowRationale -> {
|
||||
PermissionRequestDialog(
|
||||
permissionName = "Microphone",
|
||||
rationale = "Microphone access is required to record sleep sounds",
|
||||
onConfirm = { micPermissionState.launchPermissionRequest() },
|
||||
onDismiss = { /* Handle dismissal */ }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Button(onClick = { micPermissionState.launchPermissionRequest() }) {
|
||||
Text("Grant Microphone Permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Integration
|
||||
|
||||
### Jetpack Navigation with Compose
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SleepTrackingNavHost(
|
||||
navController: NavHostController = rememberNavController()
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "tracking"
|
||||
) {
|
||||
composable("tracking") {
|
||||
SleepTrackingScreen(
|
||||
onSessionComplete = { sessionId ->
|
||||
navController.navigate("results/$sessionId")
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"results/{sessionId}",
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
ResultsScreen(
|
||||
sessionId = backStackEntry.arguments?.getString("sessionId")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **ViewBinding**: Always nullify binding in `onDestroyView()` for fragments
|
||||
2. **Compose**: Use `collectAsState()` for StateFlow and `observeAsState()` for LiveData
|
||||
3. **State Management**: Handle all possible states in UI, including loading and error states
|
||||
4. **Accessibility**: Add content descriptions for all interactive elements
|
||||
5. **Dark Mode**: Support both light and dark themes
|
||||
6. **Orientation**: Handle configuration changes with ViewModels
|
||||
7. **Touch Targets**: Ensure buttons are at least 48dp in size
|
||||
519
skills/sleeptrack-be/SKILL.md
Normal file
519
skills/sleeptrack-be/SKILL.md
Normal file
@@ -0,0 +1,519 @@
|
||||
---
|
||||
name: sleeptrack-be
|
||||
description: This skill provides comprehensive backend REST API integration for Asleep sleep tracking platform. Use this skill when building server-side applications, API proxies for mobile apps, webhook event handlers, cross-platform backends (React Native, Flutter), analytics dashboards, or multi-tenant sleep tracking systems. Covers authentication, user management, session retrieval, statistics, webhook integration, and production-ready patterns with code examples in Python, Node.js, and curl.
|
||||
---
|
||||
|
||||
# Sleeptrack Backend API Integration
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides comprehensive guidance for integrating the Asleep REST API into backend applications. It covers server-side user management, session data retrieval, statistics aggregation, webhook event handling, and production-ready patterns for building robust sleep tracking backends.
|
||||
|
||||
**Use this skill when:**
|
||||
- Building backend/server-side sleep tracking integrations
|
||||
- Creating API proxies for mobile applications
|
||||
- Implementing webhook handlers for real-time sleep data
|
||||
- Developing cross-platform backends (React Native, Flutter)
|
||||
- Building analytics dashboards and reporting systems
|
||||
- Creating multi-tenant sleep tracking applications
|
||||
- Integrating sleep data with other health platforms
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Get Your API Key
|
||||
|
||||
1. Sign up at https://dashboard.asleep.ai
|
||||
2. Generate an API key for your application
|
||||
3. Store securely in environment variables (never commit to version control)
|
||||
|
||||
### 2. Basic Authentication
|
||||
|
||||
All API requests require the `x-api-key` header:
|
||||
|
||||
**curl:**
|
||||
```bash
|
||||
curl -X GET "https://api.asleep.ai/ai/v1/users/USER_ID" \
|
||||
-H "x-api-key: YOUR_API_KEY"
|
||||
```
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
headers = {"x-api-key": "YOUR_API_KEY"}
|
||||
response = requests.get(
|
||||
"https://api.asleep.ai/ai/v1/users/USER_ID",
|
||||
headers=headers
|
||||
)
|
||||
```
|
||||
|
||||
**Node.js:**
|
||||
```javascript
|
||||
const axios = require('axios');
|
||||
|
||||
const response = await axios.get(
|
||||
'https://api.asleep.ai/ai/v1/users/USER_ID',
|
||||
{
|
||||
headers: { 'x-api-key': 'YOUR_API_KEY' }
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Client Structure
|
||||
|
||||
Build a reusable API client to handle authentication, error handling, and common operations.
|
||||
|
||||
**Key Components:**
|
||||
- Base URL configuration (`https://api.asleep.ai`)
|
||||
- API key authentication in headers
|
||||
- Error handling for common HTTP status codes (401, 403, 404)
|
||||
- Request methods for all API endpoints
|
||||
- Session management with persistent connections
|
||||
|
||||
**For complete implementations:**
|
||||
- Python: See `references/python_client_implementation.md`
|
||||
- Node.js: See `references/nodejs_client_implementation.md`
|
||||
- REST API details: See `references/rest_api_reference.md`
|
||||
|
||||
**Basic Client Structure:**
|
||||
```python
|
||||
class AsleepClient:
|
||||
def __init__(self, api_key: str):
|
||||
self.api_key = api_key
|
||||
self.base_url = "https://api.asleep.ai"
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"x-api-key": api_key})
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs):
|
||||
# Handle authentication and errors
|
||||
# See python_client_implementation.md for full code
|
||||
pass
|
||||
```
|
||||
|
||||
## User Management
|
||||
|
||||
### Creating Users
|
||||
|
||||
Create users before tracking sleep. User IDs are managed by your application.
|
||||
|
||||
```python
|
||||
# Create user with metadata
|
||||
user_id = client.create_user(metadata={
|
||||
"birth_year": 1990,
|
||||
"gender": "male",
|
||||
"height": 175.5, # cm
|
||||
"weight": 70.0 # kg
|
||||
})
|
||||
```
|
||||
|
||||
**Available Metadata Fields:**
|
||||
- `birth_year` (Integer): Birth year
|
||||
- `birth_month` (Integer): 1-12
|
||||
- `birth_day` (Integer): 1-31
|
||||
- `gender` (String): `male`, `female`, `non_binary`, `other`, `prefer_not_to_say`
|
||||
- `height` (Float): Height in cm (0-300)
|
||||
- `weight` (Float): Weight in kg (0-1000)
|
||||
|
||||
### Retrieving User Information
|
||||
|
||||
```python
|
||||
user_data = client.get_user(user_id)
|
||||
# Returns: user_id, to_be_deleted status, last_session_info, metadata
|
||||
```
|
||||
|
||||
**Response includes:**
|
||||
- User ID and deletion status
|
||||
- Last session information (if available)
|
||||
- User metadata (demographic information)
|
||||
|
||||
### Deleting Users
|
||||
|
||||
Permanently removes user and all associated data (sessions, reports).
|
||||
|
||||
```python
|
||||
client.delete_user(user_id)
|
||||
```
|
||||
|
||||
**For detailed examples and response structures:**
|
||||
- See `references/rest_api_reference.md` (User Management section)
|
||||
- See `references/python_client_implementation.md` or `references/nodejs_client_implementation.md`
|
||||
|
||||
## Session Management
|
||||
|
||||
### Retrieving Session Details
|
||||
|
||||
Get comprehensive sleep analysis for a specific session.
|
||||
|
||||
```python
|
||||
session = client.get_session(
|
||||
session_id="session123",
|
||||
user_id="user123",
|
||||
timezone="America/New_York"
|
||||
)
|
||||
|
||||
# Access key metrics
|
||||
print(f"Sleep efficiency: {session['stat']['sleep_efficiency']:.1f}%")
|
||||
print(f"Total sleep time: {session['stat']['sleep_time']}")
|
||||
print(f"Sleep stages: {session['session']['sleep_stages']}")
|
||||
```
|
||||
|
||||
**Sleep Stage Values:**
|
||||
- `-1`: Unknown/No data
|
||||
- `0`: Wake
|
||||
- `1`: Light sleep
|
||||
- `2`: Deep sleep
|
||||
- `3`: REM sleep
|
||||
|
||||
**Key Metrics:**
|
||||
- `sleep_efficiency`: (Total sleep time / Time in bed) × 100%
|
||||
- `sleep_latency`: Time to fall asleep (seconds)
|
||||
- `waso_count`: Wake after sleep onset episodes
|
||||
- `time_in_bed`: Total time in bed (seconds)
|
||||
- `time_in_sleep`: Actual sleep time (seconds)
|
||||
- `time_in_light/deep/rem`: Stage durations (seconds)
|
||||
|
||||
### Listing Sessions
|
||||
|
||||
Retrieve multiple sessions with date filtering and pagination.
|
||||
|
||||
```python
|
||||
sessions = client.list_sessions(
|
||||
user_id="user123",
|
||||
date_gte="2024-01-01",
|
||||
date_lte="2024-01-31",
|
||||
limit=50,
|
||||
order_by="DESC"
|
||||
)
|
||||
```
|
||||
|
||||
**Pagination Example:**
|
||||
```python
|
||||
all_sessions = []
|
||||
offset = 0
|
||||
limit = 100
|
||||
|
||||
while True:
|
||||
result = client.list_sessions(
|
||||
user_id="user123",
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
sessions = result['sleep_session_list']
|
||||
all_sessions.extend(sessions)
|
||||
|
||||
if len(sessions) < limit:
|
||||
break
|
||||
offset += limit
|
||||
```
|
||||
|
||||
### Deleting Sessions
|
||||
|
||||
```python
|
||||
client.delete_session(session_id="session123", user_id="user123")
|
||||
```
|
||||
|
||||
**For detailed session data structures and examples:**
|
||||
- See `references/rest_api_reference.md` (Session Management section)
|
||||
- See client implementation references for language-specific examples
|
||||
|
||||
## Statistics and Analytics
|
||||
|
||||
### Average Statistics
|
||||
|
||||
Get aggregated sleep metrics over a time period (max 100 days).
|
||||
|
||||
```python
|
||||
stats = client.get_average_stats(
|
||||
user_id="user123",
|
||||
start_date="2024-01-01",
|
||||
end_date="2024-01-31",
|
||||
timezone="UTC"
|
||||
)
|
||||
|
||||
avg = stats['average_stats']
|
||||
print(f"Average sleep time: {avg['sleep_time']}")
|
||||
print(f"Average efficiency: {avg['sleep_efficiency']:.1f}%")
|
||||
print(f"Light sleep ratio: {avg['light_ratio']:.1%}")
|
||||
print(f"Number of sessions: {len(stats['slept_sessions'])}")
|
||||
```
|
||||
|
||||
**Returned Metrics:**
|
||||
- Average sleep time, bedtime, wake time
|
||||
- Average sleep efficiency
|
||||
- Sleep stage ratios (light, deep, REM)
|
||||
- List of sessions included in calculation
|
||||
|
||||
**For trend analysis and advanced analytics:**
|
||||
- See `references/python_client_implementation.md` (Analytics section)
|
||||
- See `references/rest_api_reference.md` (Statistics section)
|
||||
|
||||
## Webhook Integration
|
||||
|
||||
Webhooks enable real-time notifications for sleep session events.
|
||||
|
||||
### Webhook Event Types
|
||||
|
||||
1. **INFERENCE_COMPLETE**: Incremental sleep data during tracking (every 5-40 minutes)
|
||||
2. **SESSION_COMPLETE**: Final comprehensive sleep analysis when session ends
|
||||
|
||||
### Setting Up Webhook Endpoint
|
||||
|
||||
**Basic Structure:**
|
||||
```python
|
||||
@app.route('/asleep-webhook', methods=['POST'])
|
||||
def asleep_webhook():
|
||||
# 1. Verify authentication (x-api-key header)
|
||||
# 2. Parse event data
|
||||
# 3. Handle event type (INFERENCE_COMPLETE or SESSION_COMPLETE)
|
||||
# 4. Process and store data
|
||||
# 5. Return 200 response
|
||||
pass
|
||||
```
|
||||
|
||||
**Key Implementation Points:**
|
||||
- Verify `x-api-key` header matches your API key
|
||||
- Validate `x-user-id` header
|
||||
- Handle both INFERENCE_COMPLETE and SESSION_COMPLETE events
|
||||
- Implement idempotency (check if event already processed)
|
||||
- Process asynchronously for better performance
|
||||
- Return 200 status immediately
|
||||
|
||||
**For complete webhook implementations:**
|
||||
- Python (Flask): See `references/webhook_implementation_guide.md`
|
||||
- Node.js (Express): See `references/webhook_implementation_guide.md`
|
||||
- Webhook payloads: See `references/webhook_reference.md`
|
||||
|
||||
### Webhook Best Practices
|
||||
|
||||
**Idempotency:**
|
||||
Check if webhook event was already processed to avoid duplicates.
|
||||
|
||||
**Asynchronous Processing:**
|
||||
Queue webhook events for background processing and respond immediately.
|
||||
|
||||
**Error Handling:**
|
||||
Return 200 even if processing fails internally to prevent retries.
|
||||
|
||||
**For detailed patterns:**
|
||||
- See `references/webhook_implementation_guide.md`
|
||||
- See `references/production_patterns.md` (Background Jobs section)
|
||||
|
||||
## Common Backend Patterns
|
||||
|
||||
### 1. API Proxy for Mobile Apps
|
||||
|
||||
Create a backend proxy to:
|
||||
- Hide API keys from mobile clients
|
||||
- Add custom authentication
|
||||
- Implement business logic
|
||||
- Track usage and analytics
|
||||
|
||||
**Key endpoints:**
|
||||
- POST `/api/users` - Create Asleep user for authenticated app user
|
||||
- GET `/api/sessions/{id}` - Proxy session retrieval with auth
|
||||
- GET `/api/sessions` - List sessions with filtering
|
||||
- GET `/api/statistics` - Get aggregated statistics
|
||||
|
||||
**For complete implementation:**
|
||||
- See `references/python_client_implementation.md` (API Proxy section)
|
||||
|
||||
### 2. Analytics Dashboard Backend
|
||||
|
||||
Aggregate and analyze sleep data across multiple users:
|
||||
- Calculate comprehensive sleep scores
|
||||
- Generate weekly/monthly reports
|
||||
- Analyze cohort sleep patterns
|
||||
- Provide personalized insights
|
||||
|
||||
**Key features:**
|
||||
- Sleep score calculation (efficiency + consistency + duration)
|
||||
- Trend analysis over time
|
||||
- Multi-user aggregation
|
||||
- Report generation
|
||||
|
||||
**For complete implementation:**
|
||||
- See `references/python_client_implementation.md` (Analytics section)
|
||||
|
||||
### 3. Multi-Tenant Application
|
||||
|
||||
Manage sleep tracking for multiple organizations or teams:
|
||||
- Organization-level user management
|
||||
- Aggregated organization statistics
|
||||
- Role-based access control
|
||||
- Per-organization settings
|
||||
|
||||
**For complete implementation:**
|
||||
- See `references/python_client_implementation.md` (Multi-Tenant section)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
- **401 Unauthorized**: Invalid API key
|
||||
- **403 Forbidden**: Rate limit exceeded or insufficient permissions
|
||||
- **404 Not Found**: Resource does not exist
|
||||
- **422 Unprocessable Entity**: Invalid request parameters
|
||||
|
||||
### Retry Logic with Exponential Backoff
|
||||
|
||||
```python
|
||||
def retry_with_exponential_backoff(func, max_retries=3, base_delay=1.0):
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 403 and "rate limit" in str(e):
|
||||
if attempt < max_retries - 1:
|
||||
delay = min(base_delay * (2 ** attempt), 60.0)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise
|
||||
```
|
||||
|
||||
### Custom Exception Classes
|
||||
|
||||
```python
|
||||
class AsleepAPIError(Exception):
|
||||
"""Base exception for Asleep API errors"""
|
||||
pass
|
||||
|
||||
class RateLimitError(AsleepAPIError):
|
||||
"""Rate limit exceeded"""
|
||||
pass
|
||||
|
||||
class ResourceNotFoundError(AsleepAPIError):
|
||||
"""Resource not found"""
|
||||
pass
|
||||
```
|
||||
|
||||
**For comprehensive error handling:**
|
||||
- See `references/python_client_implementation.md` or `references/nodejs_client_implementation.md`
|
||||
- See `references/production_patterns.md` (Error Recovery section)
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Webhook Testing
|
||||
|
||||
Use ngrok to expose local server for webhook testing:
|
||||
|
||||
```bash
|
||||
# Start local server
|
||||
python app.py # or npm start
|
||||
|
||||
# Expose with ngrok
|
||||
ngrok http 5000
|
||||
|
||||
# Use ngrok URL in webhook configuration
|
||||
# Example: https://abc123.ngrok.io/asleep-webhook
|
||||
```
|
||||
|
||||
### Mock API Responses
|
||||
|
||||
```python
|
||||
@patch('requests.Session.request')
|
||||
def test_create_user(mock_request):
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"result": {"user_id": "test_user_123"}
|
||||
}
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
user_id = client.create_user()
|
||||
assert user_id == "test_user_123"
|
||||
```
|
||||
|
||||
**For complete testing examples:**
|
||||
- See `references/python_client_implementation.md` (Testing section)
|
||||
|
||||
## Production Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
1. **API Key Management:**
|
||||
- Store in environment variables or secret management system
|
||||
- Never commit to version control
|
||||
- Rotate keys periodically
|
||||
- Use different keys for dev/staging/production
|
||||
|
||||
2. **Webhook Security:**
|
||||
- Verify `x-api-key` header
|
||||
- Use HTTPS endpoints only
|
||||
- Implement rate limiting
|
||||
- Log all webhook attempts
|
||||
|
||||
3. **User Data Privacy:**
|
||||
- Encrypt sensitive data at rest
|
||||
- Implement proper access controls
|
||||
- Handle data deletion requests
|
||||
- Comply with GDPR/CCPA
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Caching:** Cache immutable session data
|
||||
2. **Rate Limiting:** Protect your backend from overload
|
||||
3. **Connection Pooling:** Reuse HTTP connections
|
||||
4. **Batch Processing:** Process multiple requests in parallel
|
||||
|
||||
### Monitoring
|
||||
|
||||
1. **Logging:** Structured logging for all API requests
|
||||
2. **Metrics:** Track request duration, error rates, throughput
|
||||
3. **Health Checks:** Implement `/health`, `/ready`, `/live` endpoints
|
||||
4. **Alerting:** Alert on error rate spikes or API failures
|
||||
|
||||
### Deployment
|
||||
|
||||
1. **Configuration:** Environment-based settings with validation
|
||||
2. **Health Checks:** Support Kubernetes liveness/readiness probes
|
||||
3. **Graceful Shutdown:** Handle termination signals properly
|
||||
4. **Error Recovery:** Circuit breaker pattern for API failures
|
||||
|
||||
**For comprehensive production patterns:**
|
||||
- Caching strategies: See `references/production_patterns.md`
|
||||
- Rate limiting: See `references/production_patterns.md`
|
||||
- Monitoring: See `references/production_patterns.md`
|
||||
- Deployment: See `references/production_patterns.md`
|
||||
|
||||
## Resources
|
||||
|
||||
### Reference Documentation
|
||||
|
||||
This skill includes comprehensive reference files:
|
||||
|
||||
- `references/python_client_implementation.md`: Complete Python client with all methods, analytics classes, and examples
|
||||
- `references/nodejs_client_implementation.md`: Complete Node.js client with Express integration
|
||||
- `references/webhook_implementation_guide.md`: Full webhook handlers in Python and Node.js with best practices
|
||||
- `references/rest_api_reference.md`: Complete REST API endpoint documentation with request/response examples
|
||||
- `references/webhook_reference.md`: Webhook integration guide with payload structures
|
||||
- `references/production_patterns.md`: Caching, rate limiting, monitoring, deployment, and performance optimization
|
||||
|
||||
To access detailed information:
|
||||
```
|
||||
Read references/python_client_implementation.md
|
||||
Read references/nodejs_client_implementation.md
|
||||
Read references/webhook_implementation_guide.md
|
||||
Read references/rest_api_reference.md
|
||||
Read references/webhook_reference.md
|
||||
Read references/production_patterns.md
|
||||
```
|
||||
|
||||
### Official Documentation
|
||||
|
||||
- **Main Documentation**: https://docs-en.asleep.ai
|
||||
- **API Basics**: https://docs-en.asleep.ai/docs/api-basics.md
|
||||
- **Webhook Guide**: https://docs-en.asleep.ai/docs/webhook.md
|
||||
- **Dashboard**: https://dashboard.asleep.ai
|
||||
- **LLM-Optimized Reference**: https://docs-en.asleep.ai/llms.txt
|
||||
|
||||
### Related Skills
|
||||
|
||||
- **sleeptrack-foundation**: Core concepts, authentication, data structures, and platform-agnostic patterns
|
||||
- **sleeptrack-ios**: iOS SDK integration for native iOS applications
|
||||
- **sleeptrack-android**: Android SDK integration for native Android applications
|
||||
|
||||
### Support
|
||||
|
||||
For technical support and API issues:
|
||||
- Check Dashboard for API usage and status
|
||||
- Review error logs and response codes
|
||||
- Contact support through Asleep Dashboard
|
||||
467
skills/sleeptrack-be/references/nodejs_client_implementation.md
Normal file
467
skills/sleeptrack-be/references/nodejs_client_implementation.md
Normal file
@@ -0,0 +1,467 @@
|
||||
# Node.js Client Implementation Guide
|
||||
|
||||
This reference provides complete Node.js client implementations for the Asleep API, including webhook servers and production patterns.
|
||||
|
||||
## Complete Node.js API Client
|
||||
|
||||
```javascript
|
||||
const axios = require('axios');
|
||||
|
||||
class AsleepClient {
|
||||
constructor(apiKey, baseURL = 'https://api.asleep.ai') {
|
||||
this.apiKey = apiKey;
|
||||
this.baseURL = baseURL;
|
||||
this.client = axios.create({
|
||||
baseURL: baseURL,
|
||||
headers: { 'x-api-key': apiKey }
|
||||
});
|
||||
}
|
||||
|
||||
async _request(method, path, options = {}) {
|
||||
try {
|
||||
const response = await this.client.request({
|
||||
method,
|
||||
url: path,
|
||||
...options
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const detail = error.response.data?.detail || 'Unknown error';
|
||||
|
||||
if (status === 401) {
|
||||
throw new Error('Invalid API key');
|
||||
} else if (status === 403) {
|
||||
throw new Error(`API access error: ${detail}`);
|
||||
} else if (status === 404) {
|
||||
throw new Error('Resource not found');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// User management
|
||||
async createUser(metadata = null) {
|
||||
const data = metadata ? { metadata } : {};
|
||||
const result = await this._request('POST', '/ai/v1/users', { data });
|
||||
return result.result.user_id;
|
||||
}
|
||||
|
||||
async getUser(userId) {
|
||||
const result = await this._request('GET', `/ai/v1/users/${userId}`);
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async deleteUser(userId) {
|
||||
await this._request('DELETE', `/ai/v1/users/${userId}`);
|
||||
}
|
||||
|
||||
// Session management
|
||||
async getSession(sessionId, userId, timezone = 'UTC') {
|
||||
const result = await this._request('GET', `/data/v3/sessions/${sessionId}`, {
|
||||
headers: { 'x-user-id': userId, 'timezone': timezone }
|
||||
});
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async listSessions(userId, options = {}) {
|
||||
const { dateGte, dateLte, offset = 0, limit = 20, orderBy = 'DESC' } = options;
|
||||
const params = { offset, limit, order_by: orderBy };
|
||||
if (dateGte) params.date_gte = dateGte;
|
||||
if (dateLte) params.date_lte = dateLte;
|
||||
|
||||
const result = await this._request('GET', '/data/v1/sessions', {
|
||||
headers: { 'x-user-id': userId },
|
||||
params
|
||||
});
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId, userId) {
|
||||
await this._request('DELETE', `/ai/v1/sessions/${sessionId}`, {
|
||||
headers: { 'x-user-id': userId }
|
||||
});
|
||||
}
|
||||
|
||||
// Statistics
|
||||
async getAverageStats(userId, startDate, endDate, timezone = 'UTC') {
|
||||
const result = await this._request('GET', `/data/v1/users/${userId}/average-stats`, {
|
||||
headers: { 'timezone': timezone },
|
||||
params: { start_date: startDate, end_date: endDate }
|
||||
});
|
||||
return result.result;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const client = new AsleepClient(process.env.ASLEEP_API_KEY);
|
||||
```
|
||||
|
||||
## Express Webhook Server
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
const EXPECTED_API_KEY = process.env.ASLEEP_API_KEY;
|
||||
|
||||
app.post('/asleep-webhook', async (req, res) => {
|
||||
// Verify authentication
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const userId = req.headers['x-user-id'];
|
||||
|
||||
if (apiKey !== EXPECTED_API_KEY) {
|
||||
console.warn('Unauthorized webhook attempt');
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { event, session_id, stat } = req.body;
|
||||
console.log(`Received ${event} event for user ${userId}`);
|
||||
|
||||
try {
|
||||
switch (event) {
|
||||
case 'INFERENCE_COMPLETE':
|
||||
await handleInferenceComplete(req.body);
|
||||
break;
|
||||
case 'SESSION_COMPLETE':
|
||||
await handleSessionComplete(req.body);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown event type: ${event}`);
|
||||
}
|
||||
|
||||
res.status(200).json({ status: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Webhook processing error:', error);
|
||||
res.status(500).json({ error: 'Processing failed' });
|
||||
}
|
||||
});
|
||||
|
||||
async function handleInferenceComplete(event) {
|
||||
const { session_id, user_id, sleep_stages } = event;
|
||||
|
||||
// Update real-time dashboard
|
||||
await updateLiveDashboard(session_id, sleep_stages);
|
||||
|
||||
// Store incremental data
|
||||
await db.collection('incremental_data').insertOne(event);
|
||||
|
||||
console.log(`Processed INFERENCE_COMPLETE for session ${session_id}`);
|
||||
}
|
||||
|
||||
async function handleSessionComplete(event) {
|
||||
const { session_id, user_id, stat, session } = event;
|
||||
|
||||
// Store complete report
|
||||
await db.collection('sleep_reports').insertOne({
|
||||
user_id,
|
||||
session_id,
|
||||
date: session.start_time,
|
||||
statistics: stat,
|
||||
session_data: session,
|
||||
created_at: new Date()
|
||||
});
|
||||
|
||||
// Send user notification
|
||||
await sendPushNotification(user_id, {
|
||||
title: 'Sleep Report Ready',
|
||||
body: `Sleep time: ${stat.sleep_time}, Efficiency: ${stat.sleep_efficiency.toFixed(1)}%`
|
||||
});
|
||||
|
||||
// Update user statistics
|
||||
await updateUserAggregatedStats(user_id);
|
||||
|
||||
console.log(`Processed SESSION_COMPLETE for session ${session_id}`);
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Webhook server listening on port ${PORT}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Retry with Exponential Backoff
|
||||
|
||||
```javascript
|
||||
async function retryWithExponentialBackoff(
|
||||
func,
|
||||
maxRetries = 3,
|
||||
baseDelay = 1000,
|
||||
maxDelay = 60000
|
||||
) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await func();
|
||||
} catch (error) {
|
||||
if (error.response?.status === 403) {
|
||||
const detail = error.response.data?.detail || '';
|
||||
if (detail.toLowerCase().includes('rate limit')) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
||||
console.log(`Rate limited, retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await retryWithExponentialBackoff(
|
||||
() => client.getSession('session123', 'user123')
|
||||
);
|
||||
```
|
||||
|
||||
## Basic Usage Examples
|
||||
|
||||
### Creating Users
|
||||
|
||||
```javascript
|
||||
// Create user with metadata
|
||||
const userId = await client.createUser({
|
||||
birth_year: 1990,
|
||||
gender: 'male',
|
||||
height: 175.5,
|
||||
weight: 70.0
|
||||
});
|
||||
console.log(`Created user: ${userId}`);
|
||||
|
||||
// Create user without metadata
|
||||
const userId = await client.createUser();
|
||||
```
|
||||
|
||||
### Getting Sessions
|
||||
|
||||
```javascript
|
||||
// Get sessions for date range
|
||||
const sessions = await client.listSessions('user123', {
|
||||
dateGte: '2024-01-01',
|
||||
dateLte: '2024-01-31',
|
||||
limit: 50,
|
||||
orderBy: 'DESC'
|
||||
});
|
||||
|
||||
console.log(`Found ${sessions.sleep_session_list.length} sessions`);
|
||||
|
||||
sessions.sleep_session_list.forEach(session => {
|
||||
console.log(`Session ${session.session_id}: ${session.session_start_time}`);
|
||||
console.log(` State: ${session.state}, Time in bed: ${session.time_in_bed}s`);
|
||||
});
|
||||
```
|
||||
|
||||
### Getting Session Details
|
||||
|
||||
```javascript
|
||||
const session = await client.getSession(
|
||||
'session123',
|
||||
'user123',
|
||||
'America/New_York'
|
||||
);
|
||||
|
||||
console.log(`Sleep efficiency: ${session.stat.sleep_efficiency.toFixed(1)}%`);
|
||||
console.log(`Total sleep time: ${session.stat.sleep_time}`);
|
||||
console.log(`Sleep stages: ${session.session.sleep_stages}`);
|
||||
console.log(`Sleep cycles: ${session.stat.sleep_cycle.length}`);
|
||||
```
|
||||
|
||||
### Getting Statistics
|
||||
|
||||
```javascript
|
||||
const stats = await client.getAverageStats(
|
||||
'user123',
|
||||
'2024-01-01',
|
||||
'2024-01-31',
|
||||
'UTC'
|
||||
);
|
||||
|
||||
const avg = stats.average_stats;
|
||||
console.log(`Average sleep time: ${avg.sleep_time}`);
|
||||
console.log(`Average efficiency: ${avg.sleep_efficiency.toFixed(1)}%`);
|
||||
console.log(`Average bedtime: ${avg.start_time}`);
|
||||
console.log(`Average wake time: ${avg.end_time}`);
|
||||
console.log(`Light sleep ratio: ${(avg.light_ratio * 100).toFixed(1)}%`);
|
||||
console.log(`Deep sleep ratio: ${(avg.deep_ratio * 100).toFixed(1)}%`);
|
||||
console.log(`REM sleep ratio: ${(avg.rem_ratio * 100).toFixed(1)}%`);
|
||||
console.log(`Number of sessions: ${stats.slept_sessions.length}`);
|
||||
```
|
||||
|
||||
## Asynchronous Webhook Processing
|
||||
|
||||
```javascript
|
||||
const Queue = require('bull');
|
||||
|
||||
const webhookQueue = new Queue('asleep-webhooks', {
|
||||
redis: {
|
||||
host: 'localhost',
|
||||
port: 6379
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/webhook', async (req, res) => {
|
||||
const event = req.body;
|
||||
|
||||
// Queue for async processing
|
||||
await webhookQueue.add(event);
|
||||
|
||||
// Respond immediately
|
||||
res.status(200).json({ status: 'queued' });
|
||||
});
|
||||
|
||||
// Process queued webhooks
|
||||
webhookQueue.process(async (job) => {
|
||||
const event = job.data;
|
||||
|
||||
if (event.event === 'SESSION_COMPLETE') {
|
||||
await handleSessionComplete(event);
|
||||
} else if (event.event === 'INFERENCE_COMPLETE') {
|
||||
await handleInferenceComplete(event);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Idempotency Pattern
|
||||
|
||||
```javascript
|
||||
async function handleSessionComplete(event) {
|
||||
const sessionId = event.session_id;
|
||||
|
||||
// Check if already processed
|
||||
const existing = await db.collection('processed_webhooks').findOne({
|
||||
session_id: sessionId,
|
||||
event: 'SESSION_COMPLETE'
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log(`Session ${sessionId} already processed, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process event
|
||||
await saveSleepReport(event);
|
||||
|
||||
// Mark as processed
|
||||
await db.collection('processed_webhooks').insertOne({
|
||||
session_id: sessionId,
|
||||
event: 'SESSION_COMPLETE',
|
||||
processed_at: new Date()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Comprehensive Error Handling
|
||||
|
||||
```javascript
|
||||
class AsleepAPIError extends Error {
|
||||
constructor(message, statusCode, detail) {
|
||||
super(message);
|
||||
this.name = 'AsleepAPIError';
|
||||
this.statusCode = statusCode;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends AsleepAPIError {
|
||||
constructor(detail) {
|
||||
super('Rate limit exceeded', 403, detail);
|
||||
this.name = 'RateLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceNotFoundError extends AsleepAPIError {
|
||||
constructor(detail) {
|
||||
super('Resource not found', 404, detail);
|
||||
this.name = 'ResourceNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
async function safeApiRequest(requestFunc) {
|
||||
try {
|
||||
return await requestFunc();
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const detail = error.response.data?.detail || 'Unknown error';
|
||||
|
||||
if (status === 401) {
|
||||
throw new AsleepAPIError('Authentication failed', 401, detail);
|
||||
} else if (status === 403) {
|
||||
if (detail.toLowerCase().includes('rate limit')) {
|
||||
throw new RateLimitError(detail);
|
||||
} else {
|
||||
throw new AsleepAPIError('Access forbidden', 403, detail);
|
||||
}
|
||||
} else if (status === 404) {
|
||||
throw new ResourceNotFoundError(detail);
|
||||
} else {
|
||||
throw new AsleepAPIError(`API error (${status})`, status, detail);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
try {
|
||||
const user = await safeApiRequest(() => client.getUser('user123'));
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
console.log('User not found, creating new user...');
|
||||
const userId = await client.createUser();
|
||||
} else if (error instanceof RateLimitError) {
|
||||
console.log('Rate limited, try again later');
|
||||
} else if (error instanceof AsleepAPIError) {
|
||||
console.error(`API error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Production Configuration
|
||||
|
||||
```javascript
|
||||
// config.js
|
||||
require('dotenv').config();
|
||||
|
||||
class Config {
|
||||
static get ASLEEP_API_KEY() {
|
||||
return process.env.ASLEEP_API_KEY;
|
||||
}
|
||||
|
||||
static get ASLEEP_BASE_URL() {
|
||||
return process.env.ASLEEP_BASE_URL || 'https://api.asleep.ai';
|
||||
}
|
||||
|
||||
static get DATABASE_URL() {
|
||||
return process.env.DATABASE_URL;
|
||||
}
|
||||
|
||||
static get REDIS_URL() {
|
||||
return process.env.REDIS_URL;
|
||||
}
|
||||
|
||||
static get WEBHOOK_SECRET() {
|
||||
return process.env.WEBHOOK_SECRET;
|
||||
}
|
||||
|
||||
static get ENABLE_CACHING() {
|
||||
return process.env.ENABLE_CACHING !== 'false';
|
||||
}
|
||||
|
||||
static validate() {
|
||||
if (!this.ASLEEP_API_KEY) {
|
||||
throw new Error('ASLEEP_API_KEY environment variable required');
|
||||
}
|
||||
if (!this.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL environment variable required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Config;
|
||||
```
|
||||
696
skills/sleeptrack-be/references/production_patterns.md
Normal file
696
skills/sleeptrack-be/references/production_patterns.md
Normal file
@@ -0,0 +1,696 @@
|
||||
# Production Patterns and Best Practices
|
||||
|
||||
This reference provides comprehensive production-ready patterns for deploying and maintaining Asleep API integrations in production environments.
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### Session Caching
|
||||
|
||||
Sessions are immutable once complete, making them ideal for caching:
|
||||
|
||||
```python
|
||||
from functools import lru_cache
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import redis
|
||||
|
||||
redis_client = redis.Redis(host='localhost', port=6379, db=0)
|
||||
|
||||
class CachedAsleepClient(AsleepClient):
|
||||
"""Client with response caching"""
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_session_cached(self, session_id: str, user_id: str) -> Dict:
|
||||
"""Get session with caching (sessions are immutable once complete)"""
|
||||
return self.get_session(session_id, user_id)
|
||||
|
||||
def get_recent_sessions(self, user_id: str, days: int = 7) -> List[Dict]:
|
||||
"""Get recent sessions with Redis caching"""
|
||||
cache_key = f"sessions:{user_id}:{days}"
|
||||
cached = redis_client.get(cache_key)
|
||||
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
result = self.list_sessions(
|
||||
user_id=user_id,
|
||||
date_gte=start_date.strftime("%Y-%m-%d"),
|
||||
date_lte=end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
# Cache for 5 minutes
|
||||
redis_client.setex(cache_key, 300, json.dumps(result))
|
||||
|
||||
return result
|
||||
|
||||
def invalidate_user_cache(self, user_id: str):
|
||||
"""Invalidate all caches for a user"""
|
||||
pattern = f"sessions:{user_id}:*"
|
||||
for key in redis_client.scan_iter(match=pattern):
|
||||
redis_client.delete(key)
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Application-Level Rate Limiting
|
||||
|
||||
Protect your backend from being overwhelmed:
|
||||
|
||||
```python
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
limiter = Limiter(
|
||||
app,
|
||||
key_func=get_remote_address,
|
||||
default_limits=["100 per hour"]
|
||||
)
|
||||
|
||||
@app.route('/api/sessions/<session_id>')
|
||||
@limiter.limit("10 per minute")
|
||||
def get_session(session_id):
|
||||
"""Rate-limited session endpoint"""
|
||||
# Implementation
|
||||
pass
|
||||
|
||||
@app.route('/api/statistics')
|
||||
@limiter.limit("5 per minute")
|
||||
def get_statistics():
|
||||
"""Statistics endpoint with stricter rate limiting"""
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
### API Request Rate Limiting
|
||||
|
||||
Respect Asleep API rate limits with request throttling:
|
||||
|
||||
```python
|
||||
import time
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
|
||||
class RateLimitedClient(AsleepClient):
|
||||
"""Client with built-in rate limiting"""
|
||||
|
||||
def __init__(self, api_key: str, requests_per_second: int = 10):
|
||||
super().__init__(api_key)
|
||||
self.requests_per_second = requests_per_second
|
||||
self.request_times = deque()
|
||||
self.lock = Lock()
|
||||
|
||||
def _wait_for_rate_limit(self):
|
||||
"""Wait if necessary to stay within rate limits"""
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
|
||||
# Remove requests older than 1 second
|
||||
while self.request_times and self.request_times[0] < now - 1:
|
||||
self.request_times.popleft()
|
||||
|
||||
# If at limit, wait
|
||||
if len(self.request_times) >= self.requests_per_second:
|
||||
sleep_time = 1 - (now - self.request_times[0])
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
self.request_times.popleft()
|
||||
|
||||
self.request_times.append(time.time())
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs):
|
||||
"""Rate-limited request"""
|
||||
self._wait_for_rate_limit()
|
||||
return super()._request(method, path, **kwargs)
|
||||
```
|
||||
|
||||
## Connection Pooling
|
||||
|
||||
### HTTP Session with Connection Pool
|
||||
|
||||
Reuse connections for better performance:
|
||||
|
||||
```python
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
def create_session_with_retries():
|
||||
"""Create session with connection pooling and retries"""
|
||||
session = requests.Session()
|
||||
|
||||
retry_strategy = Retry(
|
||||
total=3,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
method_whitelist=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(
|
||||
max_retries=retry_strategy,
|
||||
pool_connections=10,
|
||||
pool_maxsize=20
|
||||
)
|
||||
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
|
||||
return session
|
||||
|
||||
class PooledAsleepClient(AsleepClient):
|
||||
"""Client with connection pooling"""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
self.api_key = api_key
|
||||
self.base_url = "https://api.asleep.ai"
|
||||
self.session = create_session_with_retries()
|
||||
self.session.headers.update({"x-api-key": api_key})
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Structured Logging
|
||||
|
||||
```python
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
class StructuredLogger:
|
||||
"""Structured logging for API requests"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.logger = logging.getLogger(name)
|
||||
|
||||
def log_request(self, method: str, path: str, user_id: str = None):
|
||||
"""Log API request"""
|
||||
self.logger.info(json.dumps({
|
||||
'event': 'api_request',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'method': method,
|
||||
'path': path,
|
||||
'user_id': user_id
|
||||
}))
|
||||
|
||||
def log_response(self, method: str, path: str, status_code: int, duration: float):
|
||||
"""Log API response"""
|
||||
self.logger.info(json.dumps({
|
||||
'event': 'api_response',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'method': method,
|
||||
'path': path,
|
||||
'status_code': status_code,
|
||||
'duration_ms': duration * 1000
|
||||
}))
|
||||
|
||||
def log_error(self, method: str, path: str, error: Exception, duration: float):
|
||||
"""Log API error"""
|
||||
self.logger.error(json.dumps({
|
||||
'event': 'api_error',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'method': method,
|
||||
'path': path,
|
||||
'error_type': type(error).__name__,
|
||||
'error_message': str(error),
|
||||
'duration_ms': duration * 1000
|
||||
}))
|
||||
|
||||
class MonitoredAsleepClient(AsleepClient):
|
||||
"""Client with comprehensive logging"""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
super().__init__(api_key)
|
||||
self.logger = StructuredLogger(__name__)
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs):
|
||||
"""Monitored API request"""
|
||||
start_time = datetime.now()
|
||||
user_id = kwargs.get('headers', {}).get('x-user-id')
|
||||
|
||||
self.logger.log_request(method, path, user_id)
|
||||
|
||||
try:
|
||||
result = super()._request(method, path, **kwargs)
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
self.logger.log_response(method, path, 200, duration)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
self.logger.log_error(method, path, e, duration)
|
||||
raise
|
||||
```
|
||||
|
||||
### Metrics Collection
|
||||
|
||||
```python
|
||||
from datadog import statsd
|
||||
|
||||
class MetricsClient(AsleepClient):
|
||||
"""Client with metrics collection"""
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs):
|
||||
"""Request with metrics"""
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
result = super()._request(method, path, **kwargs)
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# Record success metrics
|
||||
statsd.increment('asleep_api.request.success')
|
||||
statsd.timing('asleep_api.request.duration', duration)
|
||||
statsd.histogram('asleep_api.response_time', duration)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# Record error metrics
|
||||
statsd.increment('asleep_api.request.error')
|
||||
statsd.increment(f'asleep_api.error.{type(e).__name__}')
|
||||
statsd.timing('asleep_api.request.duration', duration)
|
||||
|
||||
raise
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### API Key Management
|
||||
|
||||
```python
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
class SecureConfig:
|
||||
"""Secure configuration management"""
|
||||
|
||||
def __init__(self):
|
||||
load_dotenv()
|
||||
self._validate_config()
|
||||
|
||||
def _validate_config(self):
|
||||
"""Validate required environment variables"""
|
||||
required = ['ASLEEP_API_KEY', 'DATABASE_URL']
|
||||
missing = [var for var in required if not os.getenv(var)]
|
||||
|
||||
if missing:
|
||||
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
|
||||
|
||||
@property
|
||||
def asleep_api_key(self) -> str:
|
||||
"""Get API key from environment"""
|
||||
return os.getenv('ASLEEP_API_KEY')
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Get database URL from environment"""
|
||||
return os.getenv('DATABASE_URL')
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Get Redis URL from environment"""
|
||||
return os.getenv('REDIS_URL', 'redis://localhost:6379')
|
||||
```
|
||||
|
||||
### Webhook Security
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""Verify webhook payload signature"""
|
||||
expected_signature = hmac.new(
|
||||
secret.encode(),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
@app.route('/asleep-webhook', methods=['POST'])
|
||||
def secure_webhook():
|
||||
"""Webhook endpoint with signature verification"""
|
||||
# Verify API key
|
||||
api_key = request.headers.get('x-api-key')
|
||||
if api_key != EXPECTED_API_KEY:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
# Verify signature (if implemented)
|
||||
signature = request.headers.get('x-signature')
|
||||
if signature:
|
||||
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
|
||||
return jsonify({"error": "Invalid signature"}), 401
|
||||
|
||||
# Process webhook
|
||||
event = request.json
|
||||
process_webhook(event)
|
||||
|
||||
return jsonify({"status": "success"}), 200
|
||||
```
|
||||
|
||||
## Deployment Configuration
|
||||
|
||||
### Environment-Based Configuration
|
||||
|
||||
```python
|
||||
import os
|
||||
from enum import Enum
|
||||
|
||||
class Environment(Enum):
|
||||
DEVELOPMENT = "development"
|
||||
STAGING = "staging"
|
||||
PRODUCTION = "production"
|
||||
|
||||
class Config:
|
||||
"""Environment-based configuration"""
|
||||
|
||||
def __init__(self):
|
||||
self.env = Environment(os.getenv('ENVIRONMENT', 'development'))
|
||||
self.asleep_api_key = os.getenv('ASLEEP_API_KEY')
|
||||
self.asleep_base_url = os.getenv('ASLEEP_BASE_URL', 'https://api.asleep.ai')
|
||||
self.database_url = os.getenv('DATABASE_URL')
|
||||
self.redis_url = os.getenv('REDIS_URL')
|
||||
|
||||
# Feature flags
|
||||
self.enable_caching = self._parse_bool('ENABLE_CACHING', True)
|
||||
self.enable_webhooks = self._parse_bool('ENABLE_WEBHOOKS', True)
|
||||
self.enable_metrics = self._parse_bool('ENABLE_METRICS', True)
|
||||
|
||||
# Performance settings
|
||||
self.max_connections = int(os.getenv('MAX_CONNECTIONS', '100'))
|
||||
self.request_timeout = int(os.getenv('REQUEST_TIMEOUT', '30'))
|
||||
|
||||
self._validate()
|
||||
|
||||
def _parse_bool(self, key: str, default: bool) -> bool:
|
||||
"""Parse boolean environment variable"""
|
||||
value = os.getenv(key, str(default)).lower()
|
||||
return value in ('true', '1', 'yes')
|
||||
|
||||
def _validate(self):
|
||||
"""Validate configuration"""
|
||||
if not self.asleep_api_key:
|
||||
raise ValueError("ASLEEP_API_KEY is required")
|
||||
|
||||
if self.env == Environment.PRODUCTION:
|
||||
if not self.database_url:
|
||||
raise ValueError("DATABASE_URL is required in production")
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.env == Environment.PRODUCTION
|
||||
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
return self.env == Environment.DEVELOPMENT
|
||||
```
|
||||
|
||||
### Health Check Endpoint
|
||||
|
||||
```python
|
||||
from flask import Flask, jsonify
|
||||
import requests
|
||||
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for load balancers"""
|
||||
checks = {
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'environment': config.env.value,
|
||||
'checks': {}
|
||||
}
|
||||
|
||||
# Check database connection
|
||||
try:
|
||||
db.command('ping')
|
||||
checks['checks']['database'] = 'ok'
|
||||
except Exception as e:
|
||||
checks['status'] = 'unhealthy'
|
||||
checks['checks']['database'] = f'error: {str(e)}'
|
||||
|
||||
# Check Redis connection
|
||||
try:
|
||||
redis_client.ping()
|
||||
checks['checks']['redis'] = 'ok'
|
||||
except Exception as e:
|
||||
checks['status'] = 'unhealthy'
|
||||
checks['checks']['redis'] = f'error: {str(e)}'
|
||||
|
||||
# Check Asleep API connectivity
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{config.asleep_base_url}/health",
|
||||
headers={"x-api-key": config.asleep_api_key},
|
||||
timeout=5
|
||||
)
|
||||
if response.status_code == 200:
|
||||
checks['checks']['asleep_api'] = 'ok'
|
||||
else:
|
||||
checks['checks']['asleep_api'] = f'status: {response.status_code}'
|
||||
except Exception as e:
|
||||
checks['status'] = 'unhealthy'
|
||||
checks['checks']['asleep_api'] = f'error: {str(e)}'
|
||||
|
||||
status_code = 200 if checks['status'] == 'healthy' else 503
|
||||
return jsonify(checks), status_code
|
||||
|
||||
@app.route('/ready')
|
||||
def readiness_check():
|
||||
"""Readiness check for Kubernetes"""
|
||||
# Check if app is ready to serve traffic
|
||||
if not app.initialized:
|
||||
return jsonify({'status': 'not ready'}), 503
|
||||
|
||||
return jsonify({'status': 'ready'}), 200
|
||||
|
||||
@app.route('/live')
|
||||
def liveness_check():
|
||||
"""Liveness check for Kubernetes"""
|
||||
# Simple check that app is running
|
||||
return jsonify({'status': 'alive'}), 200
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Circuit Breaker Pattern
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class CircuitBreaker:
|
||||
"""Circuit breaker for API calls"""
|
||||
|
||||
def __init__(self, failure_threshold: int = 5, timeout: int = 60):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.timeout = timeout
|
||||
self.failure_count = 0
|
||||
self.last_failure_time = None
|
||||
self.state = 'closed' # closed, open, half_open
|
||||
|
||||
def call(self, func, *args, **kwargs):
|
||||
"""Execute function with circuit breaker"""
|
||||
if self.state == 'open':
|
||||
if datetime.now() - self.last_failure_time > timedelta(seconds=self.timeout):
|
||||
self.state = 'half_open'
|
||||
else:
|
||||
raise Exception("Circuit breaker is open")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
if self.state == 'half_open':
|
||||
self.state = 'closed'
|
||||
self.failure_count = 0
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.failure_count += 1
|
||||
self.last_failure_time = datetime.now()
|
||||
|
||||
if self.failure_count >= self.failure_threshold:
|
||||
self.state = 'open'
|
||||
|
||||
raise
|
||||
|
||||
class ResilientAsleepClient(AsleepClient):
|
||||
"""Client with circuit breaker"""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
super().__init__(api_key)
|
||||
self.circuit_breaker = CircuitBreaker()
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs):
|
||||
"""Request with circuit breaker"""
|
||||
return self.circuit_breaker.call(
|
||||
super()._request,
|
||||
method,
|
||||
path,
|
||||
**kwargs
|
||||
)
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Session Storage
|
||||
|
||||
```python
|
||||
from pymongo import MongoClient
|
||||
from datetime import datetime
|
||||
|
||||
class SessionStore:
|
||||
"""Store and retrieve sleep sessions"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.collection = db.sleep_sessions
|
||||
self._create_indexes()
|
||||
|
||||
def _create_indexes(self):
|
||||
"""Create database indexes for performance"""
|
||||
self.collection.create_index([('user_id', 1), ('session_start_time', -1)])
|
||||
self.collection.create_index([('session_id', 1)], unique=True)
|
||||
self.collection.create_index([('created_at', 1)])
|
||||
|
||||
def store_session(self, session_data: Dict):
|
||||
"""Store session in database"""
|
||||
doc = {
|
||||
'session_id': session_data['session']['id'],
|
||||
'user_id': session_data['user_id'],
|
||||
'session_start_time': session_data['session']['start_time'],
|
||||
'session_end_time': session_data['session']['end_time'],
|
||||
'statistics': session_data['stat'],
|
||||
'sleep_stages': session_data['session']['sleep_stages'],
|
||||
'created_at': datetime.now(),
|
||||
'updated_at': datetime.now()
|
||||
}
|
||||
|
||||
self.collection.update_one(
|
||||
{'session_id': doc['session_id']},
|
||||
{'$set': doc},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
def get_user_sessions(self, user_id: str, limit: int = 10) -> List[Dict]:
|
||||
"""Get recent sessions for user"""
|
||||
return list(
|
||||
self.collection
|
||||
.find({'user_id': user_id})
|
||||
.sort('session_start_time', -1)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
def get_sessions_by_date_range(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> List[Dict]:
|
||||
"""Get sessions within date range"""
|
||||
return list(
|
||||
self.collection.find({
|
||||
'user_id': user_id,
|
||||
'session_start_time': {
|
||||
'$gte': start_date,
|
||||
'$lte': end_date
|
||||
}
|
||||
})
|
||||
.sort('session_start_time', -1)
|
||||
)
|
||||
```
|
||||
|
||||
## Background Job Processing
|
||||
|
||||
### Celery Task Queue
|
||||
|
||||
```python
|
||||
from celery import Celery
|
||||
|
||||
celery = Celery('tasks', broker='redis://localhost:6379')
|
||||
|
||||
@celery.task(bind=True, max_retries=3)
|
||||
def process_webhook_task(self, webhook_data: Dict):
|
||||
"""Process webhook asynchronously"""
|
||||
try:
|
||||
if webhook_data['event'] == 'SESSION_COMPLETE':
|
||||
# Store in database
|
||||
store_session(webhook_data)
|
||||
|
||||
# Send notification
|
||||
send_notification(webhook_data['user_id'], webhook_data)
|
||||
|
||||
# Update analytics
|
||||
update_user_stats(webhook_data['user_id'])
|
||||
|
||||
except Exception as e:
|
||||
# Retry with exponential backoff
|
||||
raise self.retry(exc=e, countdown=2 ** self.request.retries)
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
"""Webhook endpoint with async processing"""
|
||||
event = request.json
|
||||
|
||||
# Queue for background processing
|
||||
process_webhook_task.delay(event)
|
||||
|
||||
# Respond immediately
|
||||
return jsonify({"status": "queued"}), 200
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Batch Processing
|
||||
|
||||
```python
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def fetch_sessions_batch(client: AsleepClient, user_ids: List[str]) -> Dict[str, List]:
|
||||
"""Fetch sessions for multiple users in parallel"""
|
||||
results = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
future_to_user = {
|
||||
executor.submit(client.list_sessions, user_id): user_id
|
||||
for user_id in user_ids
|
||||
}
|
||||
|
||||
for future in as_completed(future_to_user):
|
||||
user_id = future_to_user[future]
|
||||
try:
|
||||
results[user_id] = future.result()
|
||||
except Exception as e:
|
||||
print(f"Error fetching sessions for {user_id}: {e}")
|
||||
results[user_id] = []
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
### Query Optimization
|
||||
|
||||
```python
|
||||
def get_user_summary_optimized(client: AsleepClient, user_id: str) -> Dict:
|
||||
"""Get user summary with optimized queries"""
|
||||
# Fetch only what's needed
|
||||
user_data = client.get_user(user_id)
|
||||
|
||||
# Use average stats instead of fetching all sessions
|
||||
stats = client.get_average_stats(
|
||||
user_id=user_id,
|
||||
start_date=(datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d"),
|
||||
end_date=datetime.now().strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'last_session': user_data.get('last_session_info'),
|
||||
'monthly_average': stats['average_stats'],
|
||||
'session_count': len(stats['slept_sessions'])
|
||||
}
|
||||
```
|
||||
561
skills/sleeptrack-be/references/python_client_implementation.md
Normal file
561
skills/sleeptrack-be/references/python_client_implementation.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# Python Client Implementation Guide
|
||||
|
||||
This reference provides complete Python client implementations for the Asleep API, including advanced patterns for analytics, production usage, and multi-tenant applications.
|
||||
|
||||
## Complete Python API Client
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
class AsleepClient:
|
||||
"""Asleep API client for backend integration"""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str = "https://api.asleep.ai"):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"x-api-key": api_key})
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Make authenticated API request with error handling"""
|
||||
url = f"{self.base_url}{path}"
|
||||
req_headers = self.session.headers.copy()
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, headers=req_headers, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Handle API errors
|
||||
if e.response.status_code == 401:
|
||||
raise ValueError("Invalid API key")
|
||||
elif e.response.status_code == 403:
|
||||
error_detail = e.response.json().get("detail", "Access forbidden")
|
||||
raise ValueError(f"API access error: {error_detail}")
|
||||
elif e.response.status_code == 404:
|
||||
raise ValueError("Resource not found")
|
||||
else:
|
||||
raise
|
||||
|
||||
# User management methods
|
||||
def create_user(self, metadata: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Create new user and return user_id"""
|
||||
data = {"metadata": metadata} if metadata else {}
|
||||
result = self._request("POST", "/ai/v1/users", json=data)
|
||||
return result["result"]["user_id"]
|
||||
|
||||
def get_user(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get user information"""
|
||||
result = self._request("GET", f"/ai/v1/users/{user_id}")
|
||||
return result["result"]
|
||||
|
||||
def delete_user(self, user_id: str) -> None:
|
||||
"""Delete user and all associated data"""
|
||||
self._request("DELETE", f"/ai/v1/users/{user_id}")
|
||||
|
||||
# Session management methods
|
||||
def get_session(self, session_id: str, user_id: str, timezone: str = "UTC") -> Dict[str, Any]:
|
||||
"""Get detailed session data"""
|
||||
headers = {"x-user-id": user_id, "timezone": timezone}
|
||||
result = self._request("GET", f"/data/v3/sessions/{session_id}", headers=headers)
|
||||
return result["result"]
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: str,
|
||||
date_gte: Optional[str] = None,
|
||||
date_lte: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
order_by: str = "DESC"
|
||||
) -> Dict[str, Any]:
|
||||
"""List user sessions with filtering"""
|
||||
headers = {"x-user-id": user_id}
|
||||
params = {"offset": offset, "limit": limit, "order_by": order_by}
|
||||
if date_gte:
|
||||
params["date_gte"] = date_gte
|
||||
if date_lte:
|
||||
params["date_lte"] = date_lte
|
||||
|
||||
result = self._request("GET", "/data/v1/sessions", headers=headers, params=params)
|
||||
return result["result"]
|
||||
|
||||
def delete_session(self, session_id: str, user_id: str) -> None:
|
||||
"""Delete session and all associated data"""
|
||||
headers = {"x-user-id": user_id}
|
||||
self._request("DELETE", f"/ai/v1/sessions/{session_id}", headers=headers)
|
||||
|
||||
# Statistics methods
|
||||
def get_average_stats(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
timezone: str = "UTC"
|
||||
) -> Dict[str, Any]:
|
||||
"""Get average statistics for date range (max 100 days)"""
|
||||
headers = {"timezone": timezone}
|
||||
params = {"start_date": start_date, "end_date": end_date}
|
||||
result = self._request(
|
||||
"GET",
|
||||
f"/data/v1/users/{user_id}/average-stats",
|
||||
headers=headers,
|
||||
params=params
|
||||
)
|
||||
return result["result"]
|
||||
|
||||
# Usage
|
||||
client = AsleepClient(api_key=os.getenv("ASLEEP_API_KEY"))
|
||||
```
|
||||
|
||||
## Advanced Analytics Implementation
|
||||
|
||||
```python
|
||||
from typing import List, Dict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class SleepAnalytics:
|
||||
"""Backend analytics for sleep tracking platform"""
|
||||
|
||||
def __init__(self, client: AsleepClient, db):
|
||||
self.client = client
|
||||
self.db = db
|
||||
|
||||
def get_user_sleep_score(self, user_id: str, days: int = 30) -> Dict:
|
||||
"""Calculate comprehensive sleep score for user"""
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
stats = self.client.get_average_stats(
|
||||
user_id=user_id,
|
||||
start_date=start_date.strftime("%Y-%m-%d"),
|
||||
end_date=end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
avg = stats['average_stats']
|
||||
|
||||
# Calculate weighted sleep score (0-100)
|
||||
efficiency_score = avg['sleep_efficiency'] # Already 0-100
|
||||
consistency_score = self._calculate_consistency_score(stats)
|
||||
duration_score = self._calculate_duration_score(avg)
|
||||
|
||||
overall_score = (
|
||||
efficiency_score * 0.4 +
|
||||
consistency_score * 0.3 +
|
||||
duration_score * 0.3
|
||||
)
|
||||
|
||||
return {
|
||||
'overall_score': round(overall_score, 1),
|
||||
'efficiency_score': round(efficiency_score, 1),
|
||||
'consistency_score': round(consistency_score, 1),
|
||||
'duration_score': round(duration_score, 1),
|
||||
'period_days': days,
|
||||
'session_count': len(stats['slept_sessions'])
|
||||
}
|
||||
|
||||
def _calculate_consistency_score(self, stats: Dict) -> float:
|
||||
"""Score based on sleep schedule consistency"""
|
||||
# Implement consistency scoring based on variance in sleep times
|
||||
# Placeholder implementation
|
||||
return 80.0
|
||||
|
||||
def _calculate_duration_score(self, avg: Dict) -> float:
|
||||
"""Score based on sleep duration (7-9 hours optimal)"""
|
||||
sleep_hours = avg['time_in_sleep'] / 3600
|
||||
|
||||
if 7 <= sleep_hours <= 9:
|
||||
return 100.0
|
||||
elif 6 <= sleep_hours < 7 or 9 < sleep_hours <= 10:
|
||||
return 80.0
|
||||
elif 5 <= sleep_hours < 6 or 10 < sleep_hours <= 11:
|
||||
return 60.0
|
||||
else:
|
||||
return 40.0
|
||||
|
||||
def get_cohort_analysis(self, user_ids: List[str], days: int = 30) -> Dict:
|
||||
"""Analyze sleep patterns across user cohort"""
|
||||
cohort_data = []
|
||||
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
score = self.get_user_sleep_score(user_id, days)
|
||||
cohort_data.append({
|
||||
'user_id': user_id,
|
||||
'score': score['overall_score'],
|
||||
'efficiency': score['efficiency_score'],
|
||||
'sessions': score['session_count']
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error fetching data for user {user_id}: {e}")
|
||||
|
||||
if not cohort_data:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'cohort_size': len(cohort_data),
|
||||
'avg_score': sum(u['score'] for u in cohort_data) / len(cohort_data),
|
||||
'avg_efficiency': sum(u['efficiency'] for u in cohort_data) / len(cohort_data),
|
||||
'total_sessions': sum(u['sessions'] for u in cohort_data),
|
||||
'users': cohort_data
|
||||
}
|
||||
|
||||
def generate_weekly_report(self, user_id: str) -> Dict:
|
||||
"""Generate comprehensive weekly sleep report"""
|
||||
stats = self.client.get_average_stats(
|
||||
user_id=user_id,
|
||||
start_date=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d"),
|
||||
end_date=datetime.now().strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
avg = stats['average_stats']
|
||||
|
||||
return {
|
||||
'period': 'Last 7 days',
|
||||
'summary': {
|
||||
'avg_sleep_time': avg['sleep_time'],
|
||||
'avg_bedtime': avg['start_time'],
|
||||
'avg_wake_time': avg['end_time'],
|
||||
'avg_efficiency': avg['sleep_efficiency']
|
||||
},
|
||||
'sleep_stages': {
|
||||
'light_hours': avg['time_in_light'] / 3600,
|
||||
'deep_hours': avg['time_in_deep'] / 3600,
|
||||
'rem_hours': avg['time_in_rem'] / 3600
|
||||
},
|
||||
'insights': self._generate_insights(avg),
|
||||
'session_count': len(stats['slept_sessions'])
|
||||
}
|
||||
|
||||
def _generate_insights(self, avg: Dict) -> List[str]:
|
||||
"""Generate personalized sleep insights"""
|
||||
insights = []
|
||||
|
||||
if avg['sleep_efficiency'] < 75:
|
||||
insights.append("Your sleep efficiency is below average. Try establishing a consistent bedtime routine.")
|
||||
|
||||
if avg['deep_ratio'] < 0.15:
|
||||
insights.append("You're getting less deep sleep than optimal. Avoid caffeine after 2 PM.")
|
||||
|
||||
if avg['waso_count'] > 3:
|
||||
insights.append("You're waking up frequently during the night. Consider reducing screen time before bed.")
|
||||
|
||||
return insights
|
||||
|
||||
# Usage
|
||||
analytics = SleepAnalytics(client, db)
|
||||
score = analytics.get_user_sleep_score("user123", days=30)
|
||||
print(f"Sleep score: {score['overall_score']}/100")
|
||||
|
||||
report = analytics.generate_weekly_report("user123")
|
||||
print(f"Weekly report: {report}")
|
||||
```
|
||||
|
||||
## Multi-Tenant Application Implementation
|
||||
|
||||
```python
|
||||
class MultiTenantSleepTracker:
|
||||
"""Multi-tenant sleep tracking backend"""
|
||||
|
||||
def __init__(self, client: AsleepClient, db):
|
||||
self.client = client
|
||||
self.db = db
|
||||
|
||||
def create_organization(self, org_id: str, name: str, settings: Dict) -> Dict:
|
||||
"""Create new organization"""
|
||||
org = {
|
||||
'org_id': org_id,
|
||||
'name': name,
|
||||
'settings': settings,
|
||||
'created_at': datetime.now(),
|
||||
'user_count': 0
|
||||
}
|
||||
self.db.organizations.insert_one(org)
|
||||
return org
|
||||
|
||||
def add_user_to_organization(self, org_id: str, user_email: str, metadata: Dict = None) -> str:
|
||||
"""Add user to organization and create Asleep user"""
|
||||
# Verify organization exists
|
||||
org = self.db.organizations.find_one({'org_id': org_id})
|
||||
if not org:
|
||||
raise ValueError(f"Organization {org_id} not found")
|
||||
|
||||
# Create Asleep user
|
||||
asleep_user_id = self.client.create_user(metadata=metadata)
|
||||
|
||||
# Store user mapping
|
||||
self.db.users.insert_one({
|
||||
'org_id': org_id,
|
||||
'user_email': user_email,
|
||||
'asleep_user_id': asleep_user_id,
|
||||
'metadata': metadata,
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
|
||||
# Update organization user count
|
||||
self.db.organizations.update_one(
|
||||
{'org_id': org_id},
|
||||
{'$inc': {'user_count': 1}}
|
||||
)
|
||||
|
||||
return asleep_user_id
|
||||
|
||||
def get_organization_statistics(self, org_id: str, days: int = 30) -> Dict:
|
||||
"""Get aggregated statistics for entire organization"""
|
||||
# Get all users in organization
|
||||
users = list(self.db.users.find({'org_id': org_id}))
|
||||
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
org_stats = {
|
||||
'org_id': org_id,
|
||||
'user_count': len(users),
|
||||
'period_days': days,
|
||||
'users_data': []
|
||||
}
|
||||
|
||||
total_efficiency = 0
|
||||
total_sleep_time = 0
|
||||
total_sessions = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
stats = self.client.get_average_stats(
|
||||
user_id=user['asleep_user_id'],
|
||||
start_date=start_date.strftime("%Y-%m-%d"),
|
||||
end_date=end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
avg = stats['average_stats']
|
||||
session_count = len(stats['slept_sessions'])
|
||||
|
||||
org_stats['users_data'].append({
|
||||
'user_email': user['user_email'],
|
||||
'efficiency': avg['sleep_efficiency'],
|
||||
'sleep_time': avg['sleep_time'],
|
||||
'session_count': session_count
|
||||
})
|
||||
|
||||
total_efficiency += avg['sleep_efficiency']
|
||||
total_sleep_time += avg['time_in_sleep']
|
||||
total_sessions += session_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching stats for user {user['user_email']}: {e}")
|
||||
|
||||
if users:
|
||||
org_stats['avg_efficiency'] = total_efficiency / len(users)
|
||||
org_stats['avg_sleep_hours'] = (total_sleep_time / len(users)) / 3600
|
||||
org_stats['total_sessions'] = total_sessions
|
||||
|
||||
return org_stats
|
||||
|
||||
# Usage
|
||||
tracker = MultiTenantSleepTracker(client, db)
|
||||
|
||||
# Create organization
|
||||
tracker.create_organization(
|
||||
org_id="acme-corp",
|
||||
name="Acme Corporation",
|
||||
settings={'timezone': 'America/New_York'}
|
||||
)
|
||||
|
||||
# Add users
|
||||
tracker.add_user_to_organization("acme-corp", "john@acme.com")
|
||||
tracker.add_user_to_organization("acme-corp", "jane@acme.com")
|
||||
|
||||
# Get organization stats
|
||||
org_stats = tracker.get_organization_statistics("acme-corp", days=30)
|
||||
print(f"Organization average efficiency: {org_stats['avg_efficiency']:.1f}%")
|
||||
```
|
||||
|
||||
## FastAPI Backend Example
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
app = FastAPI()
|
||||
asleep_client = AsleepClient(api_key=os.getenv("ASLEEP_API_KEY"))
|
||||
|
||||
# Authentication dependency
|
||||
async def verify_app_token(authorization: str = Header(...)):
|
||||
"""Verify mobile app authentication"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
||||
|
||||
token = authorization[7:]
|
||||
# Verify token with your auth system
|
||||
user = verify_jwt_token(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
return user
|
||||
|
||||
# Proxy endpoints
|
||||
@app.post("/api/users")
|
||||
async def create_user(
|
||||
metadata: Optional[dict] = None,
|
||||
user: dict = Depends(verify_app_token)
|
||||
):
|
||||
"""Create Asleep user for authenticated app user"""
|
||||
try:
|
||||
# Create user in Asleep
|
||||
asleep_user_id = asleep_client.create_user(metadata=metadata)
|
||||
|
||||
# Store mapping in your database
|
||||
db.user_mappings.insert_one({
|
||||
'app_user_id': user['id'],
|
||||
'asleep_user_id': asleep_user_id,
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
|
||||
return {"user_id": asleep_user_id}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session(
|
||||
session_id: str,
|
||||
user: dict = Depends(verify_app_token)
|
||||
):
|
||||
"""Get session data for authenticated user"""
|
||||
# Get Asleep user ID from mapping
|
||||
mapping = db.user_mappings.find_one({'app_user_id': user['id']})
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
asleep_user_id = mapping['asleep_user_id']
|
||||
|
||||
# Fetch session from Asleep
|
||||
try:
|
||||
session = asleep_client.get_session(session_id, asleep_user_id)
|
||||
return session
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions(
|
||||
date_gte: Optional[str] = None,
|
||||
date_lte: Optional[str] = None,
|
||||
user: dict = Depends(verify_app_token)
|
||||
):
|
||||
"""List sessions for authenticated user"""
|
||||
mapping = db.user_mappings.find_one({'app_user_id': user['id']})
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
asleep_user_id = mapping['asleep_user_id']
|
||||
|
||||
sessions = asleep_client.list_sessions(
|
||||
user_id=asleep_user_id,
|
||||
date_gte=date_gte,
|
||||
date_lte=date_lte
|
||||
)
|
||||
|
||||
return sessions
|
||||
|
||||
@app.get("/api/statistics")
|
||||
async def get_statistics(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
user: dict = Depends(verify_app_token)
|
||||
):
|
||||
"""Get average statistics for authenticated user"""
|
||||
mapping = db.user_mappings.find_one({'app_user_id': user['id']})
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
asleep_user_id = mapping['asleep_user_id']
|
||||
|
||||
stats = asleep_client.get_average_stats(
|
||||
user_id=asleep_user_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
return stats
|
||||
```
|
||||
|
||||
## Monthly Trends Analysis
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict
|
||||
|
||||
def get_monthly_trends(client, user_id: str, months: int = 6) -> List[Dict]:
|
||||
"""Get monthly sleep trends for the past N months"""
|
||||
trends = []
|
||||
today = datetime.now()
|
||||
|
||||
for i in range(months):
|
||||
# Calculate month boundaries
|
||||
end_date = today.replace(day=1) - timedelta(days=i * 30)
|
||||
start_date = end_date - timedelta(days=30)
|
||||
|
||||
try:
|
||||
stats = client.get_average_stats(
|
||||
user_id=user_id,
|
||||
start_date=start_date.strftime("%Y-%m-%d"),
|
||||
end_date=end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
trends.append({
|
||||
'month': end_date.strftime("%Y-%m"),
|
||||
'avg_sleep_time': stats['average_stats']['sleep_time'],
|
||||
'avg_efficiency': stats['average_stats']['sleep_efficiency'],
|
||||
'session_count': len(stats['slept_sessions'])
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error fetching stats for {end_date.strftime('%Y-%m')}: {e}")
|
||||
|
||||
return trends
|
||||
|
||||
# Usage
|
||||
trends = get_monthly_trends(client, "user123", months=6)
|
||||
for trend in trends:
|
||||
print(f"{trend['month']}: {trend['avg_sleep_time']} sleep, "
|
||||
f"{trend['avg_efficiency']:.1f}% efficiency, "
|
||||
f"{trend['session_count']} sessions")
|
||||
```
|
||||
|
||||
## Pagination Pattern
|
||||
|
||||
```python
|
||||
# Fetch all sessions with pagination
|
||||
all_sessions = []
|
||||
offset = 0
|
||||
limit = 100
|
||||
|
||||
while True:
|
||||
result = client.list_sessions(
|
||||
user_id="user123",
|
||||
date_gte="2024-01-01",
|
||||
date_lte="2024-12-31",
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
sessions = result['sleep_session_list']
|
||||
all_sessions.extend(sessions)
|
||||
|
||||
if len(sessions) < limit:
|
||||
break
|
||||
|
||||
offset += limit
|
||||
|
||||
print(f"Total sessions: {len(all_sessions)}")
|
||||
```
|
||||
725
skills/sleeptrack-be/references/rest_api_reference.md
Normal file
725
skills/sleeptrack-be/references/rest_api_reference.md
Normal file
@@ -0,0 +1,725 @@
|
||||
# Asleep REST API Reference
|
||||
|
||||
This reference provides comprehensive documentation for the Asleep REST API endpoints.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.asleep.ai
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
All API requests require authentication via the `x-api-key` header:
|
||||
|
||||
```http
|
||||
x-api-key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
Obtain your API key from the [Asleep Dashboard](https://dashboard.asleep.ai).
|
||||
|
||||
## Common Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
|--------|------|----------|-------------|
|
||||
| x-api-key | String | Yes | API authentication key |
|
||||
| x-user-id | String | Conditional | Required for session operations |
|
||||
| timezone | String | No | Response timezone (default: UTC) |
|
||||
|
||||
## Response Format
|
||||
|
||||
All responses follow this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "message about the result",
|
||||
"result": { /* response data */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Common Error Codes
|
||||
|
||||
| Status | Error | Description |
|
||||
|--------|-------|-------------|
|
||||
| 401 | Unauthorized | API Key missing or invalid |
|
||||
| 403 | Plan expired | Subscription period ended |
|
||||
| 403 | Rate limit exceeded | Request quota temporarily exceeded |
|
||||
| 403 | Quota exceeded | Total usage limit surpassed |
|
||||
| 404 | Not Found | Resource doesn't exist |
|
||||
|
||||
---
|
||||
|
||||
## User Management APIs
|
||||
|
||||
### [POST] Create User
|
||||
|
||||
Creates a new user for sleep tracking.
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
POST https://api.asleep.ai/ai/v1/users
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-api-key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
**Request Body (Optional):**
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"birth_year": 1990,
|
||||
"birth_month": 5,
|
||||
"birth_day": 15,
|
||||
"gender": "male",
|
||||
"height": 175.5,
|
||||
"weight": 70.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Metadata Fields:**
|
||||
- `birth_year` (Integer): User's birth year
|
||||
- `birth_month` (Integer): User's birth month (1-12)
|
||||
- `birth_day` (Integer): User's birth day (1-31)
|
||||
- `gender` (String): One of: `male`, `female`, `non_binary`, `other`, `prefer_not_to_say`
|
||||
- `height` (Float): Height in cm (0-300)
|
||||
- `weight` (Float): Weight in kg (0-1000)
|
||||
|
||||
**Response (201 Created):**
|
||||
```json
|
||||
{
|
||||
"detail": "success",
|
||||
"result": {
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X POST "https://api.asleep.ai/ai/v1/users" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"metadata": {
|
||||
"birth_year": 1990,
|
||||
"gender": "male",
|
||||
"height": 175.5,
|
||||
"weight": 70.0
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Example (Python):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
"https://api.asleep.ai/ai/v1/users",
|
||||
headers={"x-api-key": "YOUR_API_KEY"},
|
||||
json={
|
||||
"metadata": {
|
||||
"birth_year": 1990,
|
||||
"gender": "male",
|
||||
"height": 175.5,
|
||||
"weight": 70.0
|
||||
}
|
||||
}
|
||||
)
|
||||
user_id = response.json()["result"]["user_id"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [GET] Get User
|
||||
|
||||
Retrieves user information and last session data.
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
GET https://api.asleep.ai/ai/v1/users/{user_id}
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-api-key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
- `user_id` (String): User identifier
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"detail": "success",
|
||||
"result": {
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"to_be_deleted": false,
|
||||
"last_session_info": {
|
||||
"session_id": "abc123",
|
||||
"state": "COMPLETE",
|
||||
"session_start_time": "2024-01-20T22:00:00+00:00",
|
||||
"session_end_time": "2024-01-21T06:30:00+00:00"
|
||||
},
|
||||
"metadata": {
|
||||
"birth_year": 1990,
|
||||
"birth_month": 5,
|
||||
"birth_day": 15,
|
||||
"gender": "male",
|
||||
"height": 175.5,
|
||||
"weight": 70.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Session States:**
|
||||
- `OPEN`: Session in progress, audio uploads available
|
||||
- `CLOSED`: Session terminated, analysis in progress
|
||||
- `COMPLETE`: All analysis completed
|
||||
|
||||
**Error Response (404):**
|
||||
```json
|
||||
{
|
||||
"detail": "user does not exist"
|
||||
}
|
||||
```
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X GET "https://api.asleep.ai/ai/v1/users/USER_ID" \
|
||||
-H "x-api-key: YOUR_API_KEY"
|
||||
```
|
||||
|
||||
**Example (Python):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
f"https://api.asleep.ai/ai/v1/users/{user_id}",
|
||||
headers={"x-api-key": "YOUR_API_KEY"}
|
||||
)
|
||||
user_data = response.json()["result"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [DELETE] Delete User
|
||||
|
||||
Permanently removes a user and all associated data.
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
DELETE https://api.asleep.ai/ai/v1/users/{user_id}
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-api-key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
- `user_id` (String): User identifier
|
||||
|
||||
**Response (204 No Content):**
|
||||
User information successfully deleted.
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
401 Unauthorized:
|
||||
```json
|
||||
{
|
||||
"detail": "user_id is invalid"
|
||||
}
|
||||
```
|
||||
|
||||
404 Not Found:
|
||||
```json
|
||||
{
|
||||
"detail": "user does not exist"
|
||||
}
|
||||
```
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X DELETE "https://api.asleep.ai/ai/v1/users/USER_ID" \
|
||||
-H "x-api-key: YOUR_API_KEY"
|
||||
```
|
||||
|
||||
**Example (Python):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.delete(
|
||||
f"https://api.asleep.ai/ai/v1/users/{user_id}",
|
||||
headers={"x-api-key": "YOUR_API_KEY"}
|
||||
)
|
||||
# 204 No Content on success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Management APIs
|
||||
|
||||
### [GET] Get Session
|
||||
|
||||
Retrieves comprehensive sleep analysis data for a specific session.
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
GET https://api.asleep.ai/data/v3/sessions/{session_id}
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-api-key: YOUR_API_KEY
|
||||
x-user-id: USER_ID
|
||||
timezone: Asia/Seoul # Optional, defaults to UTC
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
- `session_id` (String): Session identifier
|
||||
|
||||
**Query Parameters:**
|
||||
None
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"detail": "success",
|
||||
"result": {
|
||||
"timezone": "UTC",
|
||||
"peculiarities": [],
|
||||
"missing_data_ratio": 0.0,
|
||||
"session": {
|
||||
"id": "session123",
|
||||
"state": "COMPLETE",
|
||||
"start_time": "2024-01-20T22:00:00+00:00",
|
||||
"end_time": "2024-01-21T06:30:00+00:00",
|
||||
"sleep_stages": [0, 0, 1, 1, 2, 3, 2, 1, 0],
|
||||
"snoring_stages": [0, 0, 0, 1, 1, 0, 0, 0, 0]
|
||||
},
|
||||
"stat": {
|
||||
"sleep_time": "06:30:00",
|
||||
"sleep_index": 85.5,
|
||||
"sleep_latency": 900,
|
||||
"time_in_bed": 30600,
|
||||
"time_in_sleep": 27000,
|
||||
"time_in_light": 13500,
|
||||
"time_in_deep": 6750,
|
||||
"time_in_rem": 6750,
|
||||
"sleep_efficiency": 88.24,
|
||||
"waso_count": 2,
|
||||
"longest_waso": 300,
|
||||
"sleep_cycle": [
|
||||
{
|
||||
"order": 1,
|
||||
"start_time": "2024-01-20T22:15:00+00:00",
|
||||
"end_time": "2024-01-21T01:30:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Sleep Stages Values:**
|
||||
- `-1`: Unknown/No data
|
||||
- `0`: Wake
|
||||
- `1`: Light sleep
|
||||
- `2`: Deep sleep
|
||||
- `3`: REM sleep
|
||||
|
||||
**Snoring Stages Values:**
|
||||
- `0`: No snoring
|
||||
- `1`: Snoring detected
|
||||
|
||||
**Peculiarities:**
|
||||
- `IN_PROGRESS`: Session still being analyzed
|
||||
- `NEVER_SLEPT`: No sleep detected in session
|
||||
- `TOO_SHORT_FOR_ANALYSIS`: Session duration < 5 minutes
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
400 Bad Request:
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid timezone format"
|
||||
}
|
||||
```
|
||||
|
||||
404 Not Found:
|
||||
```json
|
||||
{
|
||||
"detail": "Session not found"
|
||||
}
|
||||
```
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X GET "https://api.asleep.ai/data/v3/sessions/SESSION_ID" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-H "x-user-id: USER_ID" \
|
||||
-H "timezone: UTC"
|
||||
```
|
||||
|
||||
**Example (Python):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
f"https://api.asleep.ai/data/v3/sessions/{session_id}",
|
||||
headers={
|
||||
"x-api-key": "YOUR_API_KEY",
|
||||
"x-user-id": user_id,
|
||||
"timezone": "UTC"
|
||||
}
|
||||
)
|
||||
session_data = response.json()["result"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [GET] List Sessions
|
||||
|
||||
Retrieves multiple sessions with filtering and pagination.
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
GET https://api.asleep.ai/data/v1/sessions
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-api-key: YOUR_API_KEY
|
||||
x-user-id: USER_ID
|
||||
timezone: UTC # Optional
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| date_gte | String (YYYY-MM-DD) | No | - | Sessions on or after this date |
|
||||
| date_lte | String (YYYY-MM-DD) | No | - | Sessions on or before this date |
|
||||
| order_by | String (ASC/DESC) | No | DESC | Sort direction by start time |
|
||||
| offset | Integer | No | 0 | Number of records to skip |
|
||||
| limit | Integer (0-100) | No | 20 | Maximum records per request |
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"detail": "success",
|
||||
"result": {
|
||||
"timezone": "UTC",
|
||||
"sleep_session_list": [
|
||||
{
|
||||
"session_id": "session123",
|
||||
"state": "COMPLETE",
|
||||
"session_start_time": "2024-01-20T22:00:00+00:00",
|
||||
"session_end_time": "2024-01-21T06:30:00+00:00",
|
||||
"created_timezone": "UTC",
|
||||
"unexpected_end_time": null,
|
||||
"last_received_seq_num": 156,
|
||||
"time_in_bed": 30600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (400):**
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid timezone"
|
||||
}
|
||||
```
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X GET "https://api.asleep.ai/data/v1/sessions?date_gte=2024-01-01&limit=10" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-H "x-user-id: USER_ID"
|
||||
```
|
||||
|
||||
**Example (Python):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
"https://api.asleep.ai/data/v1/sessions",
|
||||
headers={
|
||||
"x-api-key": "YOUR_API_KEY",
|
||||
"x-user-id": user_id
|
||||
},
|
||||
params={
|
||||
"date_gte": "2024-01-01",
|
||||
"date_lte": "2024-01-31",
|
||||
"limit": 50,
|
||||
"order_by": "DESC"
|
||||
}
|
||||
)
|
||||
sessions = response.json()["result"]["sleep_session_list"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [DELETE] Delete Session
|
||||
|
||||
Permanently removes a session and all associated data.
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
DELETE https://api.asleep.ai/ai/v1/sessions/{session_id}
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-api-key: YOUR_API_KEY
|
||||
x-user-id: USER_ID
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
- `session_id` (String): Session identifier
|
||||
|
||||
**Response (204 No Content):**
|
||||
Session, uploaded audio, and analysis data successfully deleted.
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
401 Unauthorized:
|
||||
```json
|
||||
{
|
||||
"detail": "x-user-id is invalid"
|
||||
}
|
||||
```
|
||||
|
||||
404 Not Found (User):
|
||||
```json
|
||||
{
|
||||
"detail": "user does not exist"
|
||||
}
|
||||
```
|
||||
|
||||
404 Not Found (Session):
|
||||
```json
|
||||
{
|
||||
"detail": "session does not exist"
|
||||
}
|
||||
```
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X DELETE "https://api.asleep.ai/ai/v1/sessions/SESSION_ID" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-H "x-user-id: USER_ID"
|
||||
```
|
||||
|
||||
**Example (Python):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.delete(
|
||||
f"https://api.asleep.ai/ai/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"x-api-key": "YOUR_API_KEY",
|
||||
"x-user-id": user_id
|
||||
}
|
||||
)
|
||||
# 204 No Content on success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Statistics APIs
|
||||
|
||||
### [GET] Get Average Stats
|
||||
|
||||
Retrieves average sleep metrics over a specified time period (up to 100 days).
|
||||
|
||||
**Endpoint:**
|
||||
```
|
||||
GET https://api.asleep.ai/data/v1/users/{user_id}/average-stats
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-api-key: YOUR_API_KEY
|
||||
timezone: UTC # Optional
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
- `user_id` (String): User identifier
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| start_date | String (YYYY-MM-DD) | Yes | Period start date |
|
||||
| end_date | String (YYYY-MM-DD) | Yes | Period end date (max 100 days from start) |
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"detail": "success",
|
||||
"result": {
|
||||
"period": {
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-01-31",
|
||||
"days": 31
|
||||
},
|
||||
"peculiarities": [],
|
||||
"average_stats": {
|
||||
"start_time": "22:30:00",
|
||||
"end_time": "06:45:00",
|
||||
"sleep_time": "07:15:00",
|
||||
"wake_time": "06:45:00",
|
||||
"sleep_latency": 900,
|
||||
"wakeup_latency": 300,
|
||||
"time_in_bed": 30600,
|
||||
"time_in_sleep_period": 29700,
|
||||
"time_in_sleep": 26100,
|
||||
"time_in_wake": 3600,
|
||||
"time_in_light": 13050,
|
||||
"time_in_deep": 6525,
|
||||
"time_in_rem": 6525,
|
||||
"time_in_snoring": 1800,
|
||||
"time_in_no_snoring": 24300,
|
||||
"sleep_efficiency": 85.29,
|
||||
"wake_ratio": 0.12,
|
||||
"sleep_ratio": 0.88,
|
||||
"light_ratio": 0.50,
|
||||
"deep_ratio": 0.25,
|
||||
"rem_ratio": 0.25,
|
||||
"snoring_ratio": 0.07,
|
||||
"no_snoring_ratio": 0.93,
|
||||
"waso_count": 2.5,
|
||||
"longest_waso": 420,
|
||||
"sleep_cycle_count": 4.2,
|
||||
"snoring_count": 15.3
|
||||
},
|
||||
"never_slept_sessions": [],
|
||||
"slept_sessions": [
|
||||
{
|
||||
"session_id": "session123",
|
||||
"session_start_time": "2024-01-20T22:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Metrics Explanation:**
|
||||
|
||||
**Time Metrics** (HH:MM:SS format or seconds):
|
||||
- `start_time`: Average bedtime
|
||||
- `end_time`: Average wake time
|
||||
- `sleep_time`: Average time of falling asleep
|
||||
- `wake_time`: Average time of waking up
|
||||
- `sleep_latency`: Average time to fall asleep (seconds)
|
||||
- `wakeup_latency`: Average time from wake to getting up (seconds)
|
||||
- `time_in_bed`: Average total time in bed (seconds)
|
||||
- `time_in_sleep_period`: Average time from sleep onset to wake (seconds)
|
||||
- `time_in_sleep`: Average actual sleep time (seconds)
|
||||
- `time_in_wake`: Average wake time during sleep period (seconds)
|
||||
|
||||
**Sleep Stage Durations** (seconds):
|
||||
- `time_in_light`: Average light sleep duration
|
||||
- `time_in_deep`: Average deep sleep duration
|
||||
- `time_in_rem`: Average REM sleep duration
|
||||
|
||||
**Snoring Metrics** (seconds):
|
||||
- `time_in_snoring`: Average snoring duration
|
||||
- `time_in_no_snoring`: Average non-snoring duration
|
||||
|
||||
**Ratio Metrics** (0-1 decimal):
|
||||
- `sleep_efficiency`: Sleep time / Time in bed
|
||||
- `wake_ratio`, `sleep_ratio`: Wake/sleep proportions
|
||||
- `light_ratio`, `deep_ratio`, `rem_ratio`: Sleep stage proportions
|
||||
- `snoring_ratio`, `no_snoring_ratio`: Snoring proportions
|
||||
|
||||
**Event Counts**:
|
||||
- `waso_count`: Average wake after sleep onset episodes
|
||||
- `longest_waso`: Average longest wake episode (seconds)
|
||||
- `sleep_cycle_count`: Average number of sleep cycles
|
||||
- `snoring_count`: Average snoring episodes
|
||||
|
||||
**Peculiarities:**
|
||||
- `NO_BREATHING_STABILITY`: Inconsistent breathing data
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
400 Bad Request:
|
||||
```json
|
||||
{
|
||||
"detail": "The period should be less than or equal to 100 days"
|
||||
}
|
||||
```
|
||||
|
||||
404 Not Found:
|
||||
```json
|
||||
{
|
||||
"detail": "Unable to find the user of id {user_id}"
|
||||
}
|
||||
```
|
||||
|
||||
**Example (curl):**
|
||||
```bash
|
||||
curl -X GET "https://api.asleep.ai/data/v1/users/USER_ID/average-stats?start_date=2024-01-01&end_date=2024-01-31" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-H "timezone: UTC"
|
||||
```
|
||||
|
||||
**Example (Python):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
f"https://api.asleep.ai/data/v1/users/{user_id}/average-stats",
|
||||
headers={
|
||||
"x-api-key": "YOUR_API_KEY",
|
||||
"timezone": "UTC"
|
||||
},
|
||||
params={
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-01-31"
|
||||
}
|
||||
)
|
||||
stats = response.json()["result"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The Asleep API implements rate limiting to ensure fair usage:
|
||||
|
||||
- **Rate Limit Exceeded (403)**: Temporary quota exceeded
|
||||
- **Quota Exceeded (403)**: Total usage limit reached
|
||||
- **Plan Expired (403)**: Subscription period ended
|
||||
|
||||
Monitor your usage in the [Asleep Dashboard](https://dashboard.asleep.ai).
|
||||
|
||||
**Best Practices:**
|
||||
- Implement exponential backoff for retries
|
||||
- Cache responses when appropriate
|
||||
- Batch requests when possible
|
||||
- Monitor usage proactively
|
||||
|
||||
---
|
||||
|
||||
## API Versioning
|
||||
|
||||
The Asleep API uses versioned endpoints (e.g., `/v1/`, `/v3/`). Version upgrades occur when:
|
||||
|
||||
- Renaming response object fields
|
||||
- Modifying data types or enum values
|
||||
- Restructuring response objects
|
||||
- Introducing breaking changes
|
||||
|
||||
Non-breaking changes (like adding new fields) don't trigger version upgrades.
|
||||
|
||||
**Current Versions:**
|
||||
- User Management: `/ai/v1/`
|
||||
- Session Data: `/data/v3/` (Get Session), `/data/v1/` (List Sessions)
|
||||
- Statistics: `/data/v1/`
|
||||
594
skills/sleeptrack-be/references/webhook_implementation_guide.md
Normal file
594
skills/sleeptrack-be/references/webhook_implementation_guide.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# Webhook Implementation Guide
|
||||
|
||||
This reference provides complete webhook handler implementations for both Python and Node.js, including best practices and payload examples.
|
||||
|
||||
## Flask Webhook Handler (Python)
|
||||
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
import os
|
||||
import logging
|
||||
|
||||
app = Flask(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXPECTED_API_KEY = os.getenv("ASLEEP_API_KEY")
|
||||
|
||||
@app.route('/asleep-webhook', methods=['POST'])
|
||||
def asleep_webhook():
|
||||
"""Handle Asleep webhook events"""
|
||||
|
||||
# Verify authentication
|
||||
api_key = request.headers.get('x-api-key')
|
||||
user_id = request.headers.get('x-user-id')
|
||||
|
||||
if api_key != EXPECTED_API_KEY:
|
||||
logger.warning(f"Unauthorized webhook attempt from {request.remote_addr}")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
# Parse event
|
||||
event = request.json
|
||||
event_type = event.get('event')
|
||||
|
||||
logger.info(f"Received {event_type} event for user {user_id}")
|
||||
|
||||
try:
|
||||
if event_type == 'INFERENCE_COMPLETE':
|
||||
handle_inference_complete(event)
|
||||
elif event_type == 'SESSION_COMPLETE':
|
||||
handle_session_complete(event)
|
||||
else:
|
||||
logger.warning(f"Unknown event type: {event_type}")
|
||||
|
||||
return jsonify({"status": "success"}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook processing error: {e}", exc_info=True)
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
def handle_inference_complete(event):
|
||||
"""Process incremental sleep data"""
|
||||
session_id = event['session_id']
|
||||
user_id = event['user_id']
|
||||
sleep_stages = event['sleep_stages']
|
||||
|
||||
# Update real-time dashboard
|
||||
update_live_dashboard(session_id, sleep_stages)
|
||||
|
||||
# Store incremental data
|
||||
db.incremental_data.insert_one(event)
|
||||
|
||||
logger.info(f"Processed INFERENCE_COMPLETE for session {session_id}")
|
||||
|
||||
def handle_session_complete(event):
|
||||
"""Process complete sleep report"""
|
||||
session_id = event['session_id']
|
||||
user_id = event['user_id']
|
||||
stat = event['stat']
|
||||
|
||||
# Store complete report
|
||||
db.sleep_reports.insert_one({
|
||||
'user_id': user_id,
|
||||
'session_id': session_id,
|
||||
'date': event['session']['start_time'],
|
||||
'statistics': stat,
|
||||
'session_data': event['session'],
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
|
||||
# Send user notification
|
||||
send_push_notification(user_id, {
|
||||
'title': 'Sleep Report Ready',
|
||||
'body': f"Sleep time: {stat['sleep_time']}, Efficiency: {stat['sleep_efficiency']:.1f}%"
|
||||
})
|
||||
|
||||
# Update user statistics
|
||||
update_user_aggregated_stats(user_id)
|
||||
|
||||
logger.info(f"Processed SESSION_COMPLETE for session {session_id}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000)
|
||||
```
|
||||
|
||||
## Express Webhook Handler (Node.js)
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
const EXPECTED_API_KEY = process.env.ASLEEP_API_KEY;
|
||||
|
||||
app.post('/asleep-webhook', async (req, res) => {
|
||||
// Verify authentication
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const userId = req.headers['x-user-id'];
|
||||
|
||||
if (apiKey !== EXPECTED_API_KEY) {
|
||||
console.warn('Unauthorized webhook attempt');
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { event, session_id, stat } = req.body;
|
||||
console.log(`Received ${event} event for user ${userId}`);
|
||||
|
||||
try {
|
||||
switch (event) {
|
||||
case 'INFERENCE_COMPLETE':
|
||||
await handleInferenceComplete(req.body);
|
||||
break;
|
||||
case 'SESSION_COMPLETE':
|
||||
await handleSessionComplete(req.body);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown event type: ${event}`);
|
||||
}
|
||||
|
||||
res.status(200).json({ status: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Webhook processing error:', error);
|
||||
res.status(500).json({ error: 'Processing failed' });
|
||||
}
|
||||
});
|
||||
|
||||
async function handleInferenceComplete(event) {
|
||||
const { session_id, user_id, sleep_stages } = event;
|
||||
|
||||
// Update real-time dashboard
|
||||
await updateLiveDashboard(session_id, sleep_stages);
|
||||
|
||||
// Store incremental data
|
||||
await db.collection('incremental_data').insertOne(event);
|
||||
|
||||
console.log(`Processed INFERENCE_COMPLETE for session ${session_id}`);
|
||||
}
|
||||
|
||||
async function handleSessionComplete(event) {
|
||||
const { session_id, user_id, stat, session } = event;
|
||||
|
||||
// Store complete report
|
||||
await db.collection('sleep_reports').insertOne({
|
||||
user_id,
|
||||
session_id,
|
||||
date: session.start_time,
|
||||
statistics: stat,
|
||||
session_data: session,
|
||||
created_at: new Date()
|
||||
});
|
||||
|
||||
// Send user notification
|
||||
await sendPushNotification(user_id, {
|
||||
title: 'Sleep Report Ready',
|
||||
body: `Sleep time: ${stat.sleep_time}, Efficiency: ${stat.sleep_efficiency.toFixed(1)}%`
|
||||
});
|
||||
|
||||
// Update user statistics
|
||||
await updateUserAggregatedStats(user_id);
|
||||
|
||||
console.log(`Processed SESSION_COMPLETE for session ${session_id}`);
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Webhook server listening on port ${PORT}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Webhook Event Payloads
|
||||
|
||||
### INFERENCE_COMPLETE Event
|
||||
|
||||
Sent every 5-40 minutes during sleep tracking with incremental data.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "INFERENCE_COMPLETE",
|
||||
"version": "V3",
|
||||
"timestamp": "2024-01-21T06:15:00Z",
|
||||
"user_id": "user123",
|
||||
"session_id": "session123",
|
||||
"seq_num": 60,
|
||||
"inference_seq_num": 12,
|
||||
"sleep_stages": [1, 1, 2, 2, 2],
|
||||
"snoring_stages": [0, 0, 1, 1, 0]
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `event`: Event type identifier
|
||||
- `version`: API version (V3)
|
||||
- `timestamp`: Event timestamp in ISO 8601 format
|
||||
- `user_id`: User identifier
|
||||
- `session_id`: Sleep session identifier
|
||||
- `seq_num`: Sequence number for raw data
|
||||
- `inference_seq_num`: Sequence number for inference results
|
||||
- `sleep_stages`: Array of sleep stage values (see sleep stages reference)
|
||||
- `snoring_stages`: Array of snoring detection values (0 = no snoring, 1 = snoring)
|
||||
|
||||
### SESSION_COMPLETE Event
|
||||
|
||||
Sent when sleep session ends with complete analysis.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "SESSION_COMPLETE",
|
||||
"version": "V3",
|
||||
"timestamp": "2024-01-21T06:30:00Z",
|
||||
"user_id": "user123",
|
||||
"session_id": "session123",
|
||||
"session": {
|
||||
"id": "session123",
|
||||
"state": "COMPLETE",
|
||||
"start_time": "2024-01-20T22:00:00+00:00",
|
||||
"end_time": "2024-01-21T06:30:00+00:00",
|
||||
"sleep_stages": [0, 0, 1, 1, 2, 3, 2, 1, 0],
|
||||
"snoring_stages": [0, 0, 0, 1, 1, 0, 0, 0, 0]
|
||||
},
|
||||
"stat": {
|
||||
"sleep_time": "06:30:00",
|
||||
"sleep_efficiency": 88.24,
|
||||
"time_in_bed": 30600,
|
||||
"time_in_sleep": 27000,
|
||||
"time_in_wake": 3600,
|
||||
"time_in_light": 14400,
|
||||
"time_in_deep": 7200,
|
||||
"time_in_rem": 5400,
|
||||
"waso_count": 2,
|
||||
"sleep_latency": 900,
|
||||
"sleep_cycle": [
|
||||
{
|
||||
"index": 0,
|
||||
"start_time": "2024-01-20T22:15:00+00:00",
|
||||
"end_time": "2024-01-21T01:45:00+00:00"
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"start_time": "2024-01-21T01:45:00+00:00",
|
||||
"end_time": "2024-01-21T05:15:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Sleep Stage Values:**
|
||||
- `-1`: Unknown/No data
|
||||
- `0`: Wake
|
||||
- `1`: Light sleep
|
||||
- `2`: Deep sleep
|
||||
- `3`: REM sleep
|
||||
|
||||
## Webhook Best Practices
|
||||
|
||||
### 1. Idempotency
|
||||
|
||||
Handle duplicate webhook deliveries gracefully:
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
def handle_session_complete(event):
|
||||
session_id = event['session_id']
|
||||
|
||||
# Check if already processed
|
||||
if db.processed_webhooks.find_one({'session_id': session_id, 'event': 'SESSION_COMPLETE'}):
|
||||
logger.info(f"Session {session_id} already processed, skipping")
|
||||
return
|
||||
|
||||
# Process event
|
||||
save_sleep_report(event)
|
||||
|
||||
# Mark as processed
|
||||
db.processed_webhooks.insert_one({
|
||||
'session_id': session_id,
|
||||
'event': 'SESSION_COMPLETE',
|
||||
'processed_at': datetime.now()
|
||||
})
|
||||
```
|
||||
|
||||
**Node.js:**
|
||||
```javascript
|
||||
async function handleSessionComplete(event) {
|
||||
const sessionId = event.session_id;
|
||||
|
||||
// Check if already processed
|
||||
const existing = await db.collection('processed_webhooks').findOne({
|
||||
session_id: sessionId,
|
||||
event: 'SESSION_COMPLETE'
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log(`Session ${sessionId} already processed, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process event
|
||||
await saveSleepReport(event);
|
||||
|
||||
// Mark as processed
|
||||
await db.collection('processed_webhooks').insertOne({
|
||||
session_id: sessionId,
|
||||
event: 'SESSION_COMPLETE',
|
||||
processed_at: new Date()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Asynchronous Processing
|
||||
|
||||
Process webhooks asynchronously to respond quickly:
|
||||
|
||||
**Python (Celery):**
|
||||
```python
|
||||
from celery import Celery
|
||||
|
||||
celery = Celery('tasks', broker='redis://localhost:6379')
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
event = request.json
|
||||
|
||||
# Queue for async processing
|
||||
process_webhook_async.delay(event)
|
||||
|
||||
# Respond immediately
|
||||
return jsonify({"status": "queued"}), 200
|
||||
|
||||
@celery.task
|
||||
def process_webhook_async(event):
|
||||
"""Process webhook asynchronously"""
|
||||
if event['event'] == 'SESSION_COMPLETE':
|
||||
handle_session_complete(event)
|
||||
```
|
||||
|
||||
**Node.js (Bull):**
|
||||
```javascript
|
||||
const Queue = require('bull');
|
||||
|
||||
const webhookQueue = new Queue('asleep-webhooks', {
|
||||
redis: { host: 'localhost', port: 6379 }
|
||||
});
|
||||
|
||||
app.post('/webhook', async (req, res) => {
|
||||
const event = req.body;
|
||||
|
||||
// Queue for async processing
|
||||
await webhookQueue.add(event);
|
||||
|
||||
// Respond immediately
|
||||
res.status(200).json({ status: 'queued' });
|
||||
});
|
||||
|
||||
// Process queued webhooks
|
||||
webhookQueue.process(async (job) => {
|
||||
const event = job.data;
|
||||
|
||||
if (event.event === 'SESSION_COMPLETE') {
|
||||
await handleSessionComplete(event);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Security
|
||||
|
||||
Always verify webhook authenticity:
|
||||
|
||||
```python
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
# Verify API key
|
||||
api_key = request.headers.get('x-api-key')
|
||||
if api_key != EXPECTED_API_KEY:
|
||||
logger.warning(f"Unauthorized webhook from {request.remote_addr}")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
# Verify user ID presence
|
||||
user_id = request.headers.get('x-user-id')
|
||||
if not user_id:
|
||||
logger.warning("Missing x-user-id header")
|
||||
return jsonify({"error": "Missing user ID"}), 400
|
||||
|
||||
# Process webhook
|
||||
# ...
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
Implement robust error handling:
|
||||
|
||||
```python
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
try:
|
||||
event = request.json
|
||||
event_type = event.get('event')
|
||||
|
||||
if event_type == 'SESSION_COMPLETE':
|
||||
handle_session_complete(event)
|
||||
elif event_type == 'INFERENCE_COMPLETE':
|
||||
handle_inference_complete(event)
|
||||
else:
|
||||
logger.warning(f"Unknown event type: {event_type}")
|
||||
return jsonify({"error": "Unknown event type"}), 400
|
||||
|
||||
return jsonify({"status": "success"}), 200
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Processing error: {e}", exc_info=True)
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
```
|
||||
|
||||
### 5. Logging
|
||||
|
||||
Log all webhook events for debugging:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('webhooks.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
user_id = request.headers.get('x-user-id')
|
||||
event = request.json
|
||||
event_type = event.get('event')
|
||||
|
||||
logger.info(f"Webhook received - Type: {event_type}, User: {user_id}, Session: {event.get('session_id')}")
|
||||
|
||||
try:
|
||||
# Process webhook
|
||||
# ...
|
||||
logger.info(f"Webhook processed successfully - Session: {event.get('session_id')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook processing failed - Session: {event.get('session_id')}, Error: {e}", exc_info=True)
|
||||
```
|
||||
|
||||
## Testing Webhooks Locally
|
||||
|
||||
### Using ngrok
|
||||
|
||||
```bash
|
||||
# Start your local server
|
||||
python app.py # or npm start
|
||||
|
||||
# In another terminal, expose with ngrok
|
||||
ngrok http 5000
|
||||
|
||||
# Use the ngrok URL as webhook URL in Asleep Dashboard
|
||||
# Example: https://abc123.ngrok.io/asleep-webhook
|
||||
```
|
||||
|
||||
### Mock Webhook for Testing
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
import requests
|
||||
import json
|
||||
|
||||
def send_test_webhook(url, event_type='SESSION_COMPLETE'):
|
||||
"""Send test webhook to local server"""
|
||||
|
||||
if event_type == 'SESSION_COMPLETE':
|
||||
payload = {
|
||||
"event": "SESSION_COMPLETE",
|
||||
"version": "V3",
|
||||
"timestamp": "2024-01-21T06:30:00Z",
|
||||
"user_id": "test_user",
|
||||
"session_id": "test_session",
|
||||
"session": {
|
||||
"id": "test_session",
|
||||
"state": "COMPLETE",
|
||||
"start_time": "2024-01-20T22:00:00+00:00",
|
||||
"end_time": "2024-01-21T06:30:00+00:00",
|
||||
"sleep_stages": [0, 1, 2, 3, 2, 1, 0]
|
||||
},
|
||||
"stat": {
|
||||
"sleep_time": "06:30:00",
|
||||
"sleep_efficiency": 88.24,
|
||||
"time_in_bed": 30600,
|
||||
"time_in_sleep": 27000
|
||||
}
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"event": "INFERENCE_COMPLETE",
|
||||
"version": "V3",
|
||||
"timestamp": "2024-01-21T06:15:00Z",
|
||||
"user_id": "test_user",
|
||||
"session_id": "test_session",
|
||||
"seq_num": 60,
|
||||
"inference_seq_num": 12,
|
||||
"sleep_stages": [1, 1, 2, 2, 2]
|
||||
}
|
||||
|
||||
headers = {
|
||||
'x-api-key': 'your_api_key',
|
||||
'x-user-id': 'test_user',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
print(f"Status: {response.status_code}")
|
||||
print(f"Response: {response.json()}")
|
||||
|
||||
# Test locally
|
||||
send_test_webhook('http://localhost:5000/asleep-webhook')
|
||||
```
|
||||
|
||||
## Common Webhook Patterns
|
||||
|
||||
### Real-time Dashboard Updates
|
||||
|
||||
```python
|
||||
def handle_inference_complete(event):
|
||||
"""Update real-time dashboard with incremental data"""
|
||||
session_id = event['session_id']
|
||||
user_id = event['user_id']
|
||||
sleep_stages = event['sleep_stages']
|
||||
|
||||
# Broadcast to connected clients via WebSocket
|
||||
socketio.emit('sleep_update', {
|
||||
'session_id': session_id,
|
||||
'sleep_stages': sleep_stages,
|
||||
'timestamp': event['timestamp']
|
||||
}, room=user_id)
|
||||
```
|
||||
|
||||
### Sleep Report Notifications
|
||||
|
||||
```python
|
||||
def handle_session_complete(event):
|
||||
"""Send notification when sleep report is ready"""
|
||||
user_id = event['user_id']
|
||||
stat = event['stat']
|
||||
|
||||
# Send push notification
|
||||
send_push_notification(user_id, {
|
||||
'title': 'Your Sleep Report is Ready',
|
||||
'body': f"You slept for {stat['sleep_time']} with {stat['sleep_efficiency']:.1f}% efficiency",
|
||||
'data': {
|
||||
'session_id': event['session_id'],
|
||||
'action': 'view_report'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Data Aggregation
|
||||
|
||||
```python
|
||||
def handle_session_complete(event):
|
||||
"""Update aggregated user statistics"""
|
||||
user_id = event['user_id']
|
||||
stat = event['stat']
|
||||
|
||||
# Update rolling averages
|
||||
db.user_stats.update_one(
|
||||
{'user_id': user_id},
|
||||
{
|
||||
'$inc': {
|
||||
'total_sessions': 1,
|
||||
'total_sleep_time': stat['time_in_sleep']
|
||||
},
|
||||
'$push': {
|
||||
'recent_efficiency': {
|
||||
'$each': [stat['sleep_efficiency']],
|
||||
'$slice': -30 # Keep last 30 sessions
|
||||
}
|
||||
}
|
||||
},
|
||||
upsert=True
|
||||
)
|
||||
```
|
||||
821
skills/sleeptrack-be/references/webhook_reference.md
Normal file
821
skills/sleeptrack-be/references/webhook_reference.md
Normal file
@@ -0,0 +1,821 @@
|
||||
# Asleep Webhook Reference
|
||||
|
||||
This reference provides comprehensive documentation for implementing Asleep webhooks in backend applications.
|
||||
|
||||
## Overview
|
||||
|
||||
Asleep webhooks enable real-time notifications about sleep session events. The system sends HTTP POST requests to your configured callback URL when specific events occur.
|
||||
|
||||
## Webhook Configuration
|
||||
|
||||
Webhooks are configured by providing a callback URL during session operations (via SDK) or through the Asleep Dashboard.
|
||||
|
||||
**Callback URL Requirements:**
|
||||
- Must be publicly accessible HTTPS endpoint
|
||||
- Should respond with 2xx status code
|
||||
- Should handle requests within 30 seconds
|
||||
|
||||
## Authentication
|
||||
|
||||
Webhook requests include authentication headers:
|
||||
|
||||
```http
|
||||
x-api-key: YOUR_API_KEY
|
||||
x-user-id: USER_ID
|
||||
```
|
||||
|
||||
**Security Best Practices:**
|
||||
- Verify the `x-api-key` matches your expected API key
|
||||
- Validate the `x-user-id` belongs to your system
|
||||
- Use HTTPS for your webhook endpoint
|
||||
- Implement request signing if needed
|
||||
- Log all webhook attempts for audit
|
||||
|
||||
## Supported Events
|
||||
|
||||
Asleep webhooks support two primary event types:
|
||||
|
||||
### 1. INFERENCE_COMPLETE
|
||||
|
||||
Triggered during sleep session analysis at regular intervals (every 5 or 40 minutes).
|
||||
|
||||
**Use Cases:**
|
||||
- Real-time sleep stage monitoring
|
||||
- Live dashboard updates
|
||||
- Progressive data analysis
|
||||
- User notifications during tracking
|
||||
|
||||
**Timing:**
|
||||
- Fires every 5 minutes during active tracking
|
||||
- May also fire at 40-minute intervals
|
||||
- Multiple events per session
|
||||
|
||||
### 2. SESSION_COMPLETE
|
||||
|
||||
Triggered when complete sleep session analysis finishes.
|
||||
|
||||
**Use Cases:**
|
||||
- Final report generation
|
||||
- User notifications
|
||||
- Data storage
|
||||
- Statistics calculation
|
||||
- Integration with other systems
|
||||
|
||||
**Timing:**
|
||||
- Fires once per session
|
||||
- Occurs after session end
|
||||
- Contains complete analysis
|
||||
|
||||
## Webhook Payload Schemas
|
||||
|
||||
### INFERENCE_COMPLETE Payload
|
||||
|
||||
Provides incremental sleep analysis data.
|
||||
|
||||
**Structure:**
|
||||
```json
|
||||
{
|
||||
"event": "INFERENCE_COMPLETE",
|
||||
"version": "V3",
|
||||
"timestamp": "2024-01-21T06:15:00Z",
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"session_id": "session123",
|
||||
"seq_num": 60,
|
||||
"inference_seq_num": 12,
|
||||
"sleep_stages": [1, 1, 2, 2, 2],
|
||||
"breath_stages": [0, 0, 0, 0, 0],
|
||||
"snoring_stages": [0, 0, 1, 1, 0],
|
||||
"time_window": {
|
||||
"start": "2024-01-21T06:10:00Z",
|
||||
"end": "2024-01-21T06:15:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| event | String | Always "INFERENCE_COMPLETE" |
|
||||
| version | String | API version (V1, V2, V3) |
|
||||
| timestamp | String (ISO 8601) | Event generation time |
|
||||
| user_id | String | User identifier |
|
||||
| session_id | String | Session identifier |
|
||||
| seq_num | Integer | Audio data upload sequence number |
|
||||
| inference_seq_num | Integer | Analysis sequence (5-minute increments) |
|
||||
| sleep_stages | Array[Integer] | Sleep stage values for time window |
|
||||
| breath_stages | Array[Integer] | Breathing stability indicators |
|
||||
| snoring_stages | Array[Integer] | Snoring detection values |
|
||||
| time_window | Object | Time range for this analysis chunk |
|
||||
|
||||
**Sleep Stage Values:**
|
||||
- `-1`: Unknown/No data
|
||||
- `0`: Wake
|
||||
- `1`: Light sleep
|
||||
- `2`: Deep sleep
|
||||
- `3`: REM sleep
|
||||
|
||||
**Snoring Stage Values:**
|
||||
- `0`: No snoring
|
||||
- `1`: Snoring detected
|
||||
|
||||
**Example Handler (Python):**
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def handle_inference():
|
||||
# Verify authentication
|
||||
api_key = request.headers.get('x-api-key')
|
||||
user_id = request.headers.get('x-user-id')
|
||||
|
||||
if api_key != EXPECTED_API_KEY:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
# Parse payload
|
||||
data = request.json
|
||||
|
||||
if data['event'] == 'INFERENCE_COMPLETE':
|
||||
session_id = data['session_id']
|
||||
sleep_stages = data['sleep_stages']
|
||||
|
||||
# Process incremental data
|
||||
update_live_dashboard(session_id, sleep_stages)
|
||||
|
||||
# Store for real-time analysis
|
||||
store_incremental_data(data)
|
||||
|
||||
return jsonify({"status": "received"}), 200
|
||||
```
|
||||
|
||||
**Example Handler (Node.js):**
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/webhook', async (req, res) => {
|
||||
// Verify authentication
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const userId = req.headers['x-user-id'];
|
||||
|
||||
if (apiKey !== process.env.ASLEEP_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { event, session_id, sleep_stages } = req.body;
|
||||
|
||||
if (event === 'INFERENCE_COMPLETE') {
|
||||
// Update real-time dashboard
|
||||
await updateLiveDashboard(session_id, sleep_stages);
|
||||
|
||||
// Store incremental data
|
||||
await storeIncrementalData(req.body);
|
||||
}
|
||||
|
||||
res.status(200).json({ status: 'received' });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### SESSION_COMPLETE Payload
|
||||
|
||||
Provides comprehensive final sleep analysis.
|
||||
|
||||
**Structure:**
|
||||
```json
|
||||
{
|
||||
"event": "SESSION_COMPLETE",
|
||||
"version": "V3",
|
||||
"timestamp": "2024-01-21T06:30:00Z",
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"session_id": "session123",
|
||||
"session": {
|
||||
"id": "session123",
|
||||
"state": "COMPLETE",
|
||||
"start_time": "2024-01-20T22:00:00+00:00",
|
||||
"end_time": "2024-01-21T06:30:00+00:00",
|
||||
"timezone": "UTC",
|
||||
"sleep_stages": [0, 0, 1, 1, 2, 3, 2, 1, 0],
|
||||
"snoring_stages": [0, 0, 0, 1, 1, 0, 0, 0, 0]
|
||||
},
|
||||
"stat": {
|
||||
"sleep_time": "06:30:00",
|
||||
"sleep_index": 85.5,
|
||||
"sleep_latency": 900,
|
||||
"time_in_bed": 30600,
|
||||
"time_in_sleep": 27000,
|
||||
"time_in_light": 13500,
|
||||
"time_in_deep": 6750,
|
||||
"time_in_rem": 6750,
|
||||
"sleep_efficiency": 88.24,
|
||||
"waso_count": 2,
|
||||
"longest_waso": 300,
|
||||
"sleep_cycle": [
|
||||
{
|
||||
"order": 1,
|
||||
"start_time": "2024-01-20T22:15:00+00:00",
|
||||
"end_time": "2024-01-21T01:30:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"peculiarities": []
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| event | String | Always "SESSION_COMPLETE" |
|
||||
| version | String | API version (V1, V2, V3) |
|
||||
| timestamp | String (ISO 8601) | Event generation time |
|
||||
| user_id | String | User identifier |
|
||||
| session_id | String | Session identifier |
|
||||
| session | Object | Complete session data |
|
||||
| stat | Object | Comprehensive sleep statistics |
|
||||
| peculiarities | Array[String] | Special session conditions |
|
||||
|
||||
**Session Object Fields:**
|
||||
- `id`: Session identifier
|
||||
- `state`: Always "COMPLETE" for this event
|
||||
- `start_time`, `end_time`: Session timestamps (ISO 8601)
|
||||
- `timezone`: Timezone of the session
|
||||
- `sleep_stages`: Complete sleep stage timeline
|
||||
- `snoring_stages`: Complete snoring timeline
|
||||
|
||||
**Stat Object Fields:**
|
||||
- `sleep_time`: Total sleep duration (HH:MM:SS)
|
||||
- `sleep_index`: Overall sleep quality score (0-100)
|
||||
- `sleep_latency`: Time to fall asleep (seconds)
|
||||
- `time_in_bed`: Total time in bed (seconds)
|
||||
- `time_in_sleep`: Total actual sleep time (seconds)
|
||||
- `time_in_light/deep/rem`: Stage durations (seconds)
|
||||
- `sleep_efficiency`: Percentage of time spent sleeping
|
||||
- `waso_count`: Wake after sleep onset episodes
|
||||
- `longest_waso`: Longest wake episode (seconds)
|
||||
- `sleep_cycle`: Array of sleep cycle objects
|
||||
|
||||
**Peculiarities:**
|
||||
- `IN_PROGRESS`: Analysis still ongoing (shouldn't occur for COMPLETE)
|
||||
- `NEVER_SLEPT`: No sleep detected
|
||||
- `TOO_SHORT_FOR_ANALYSIS`: Session < 5 minutes
|
||||
- `NO_BREATHING_STABILITY`: Inconsistent breathing data
|
||||
|
||||
**Example Handler (Python):**
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
import logging
|
||||
|
||||
app = Flask(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def handle_session_complete():
|
||||
# Verify authentication
|
||||
api_key = request.headers.get('x-api-key')
|
||||
user_id = request.headers.get('x-user-id')
|
||||
|
||||
if api_key != EXPECTED_API_KEY:
|
||||
logger.warning(f"Unauthorized webhook attempt from {request.remote_addr}")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
# Parse payload
|
||||
data = request.json
|
||||
|
||||
if data['event'] == 'SESSION_COMPLETE':
|
||||
session_id = data['session_id']
|
||||
stat = data['stat']
|
||||
|
||||
# Store complete report
|
||||
save_sleep_report(user_id, session_id, data)
|
||||
|
||||
# Send user notification
|
||||
notify_user(user_id, {
|
||||
'session_id': session_id,
|
||||
'sleep_time': stat['sleep_time'],
|
||||
'sleep_efficiency': stat['sleep_efficiency'],
|
||||
'sleep_index': stat['sleep_index']
|
||||
})
|
||||
|
||||
# Update user statistics
|
||||
update_user_statistics(user_id)
|
||||
|
||||
# Trigger integrations
|
||||
sync_to_health_platform(user_id, data)
|
||||
|
||||
logger.info(f"Processed SESSION_COMPLETE for {session_id}")
|
||||
|
||||
return jsonify({"status": "processed"}), 200
|
||||
```
|
||||
|
||||
**Example Handler (Node.js):**
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/webhook', async (req, res) => {
|
||||
// Verify authentication
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const userId = req.headers['x-user-id'];
|
||||
|
||||
if (apiKey !== process.env.ASLEEP_API_KEY) {
|
||||
console.warn('Unauthorized webhook attempt');
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { event, session_id, stat } = req.body;
|
||||
|
||||
if (event === 'SESSION_COMPLETE') {
|
||||
try {
|
||||
// Store complete report
|
||||
await saveSleepReport(userId, session_id, req.body);
|
||||
|
||||
// Send user notification
|
||||
await notifyUser(userId, {
|
||||
sessionId: session_id,
|
||||
sleepTime: stat.sleep_time,
|
||||
sleepEfficiency: stat.sleep_efficiency,
|
||||
sleepIndex: stat.sleep_index
|
||||
});
|
||||
|
||||
// Update statistics
|
||||
await updateUserStatistics(userId);
|
||||
|
||||
// Sync to integrations
|
||||
await syncToHealthPlatform(userId, req.body);
|
||||
|
||||
console.log(`Processed SESSION_COMPLETE for ${session_id}`);
|
||||
|
||||
res.status(200).json({ status: 'processed' });
|
||||
} catch (error) {
|
||||
console.error('Webhook processing error:', error);
|
||||
res.status(500).json({ error: 'Processing failed' });
|
||||
}
|
||||
} else {
|
||||
res.status(200).json({ status: 'received' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Versioning
|
||||
|
||||
Webhooks support three format versions for backward compatibility:
|
||||
|
||||
### V1 (Legacy)
|
||||
Original webhook format. Use V3 for new implementations.
|
||||
|
||||
### V2 (Legacy)
|
||||
Updated format with additional fields. Use V3 for new implementations.
|
||||
|
||||
### V3 (Current)
|
||||
Latest format with comprehensive data structures. Recommended for all new integrations.
|
||||
|
||||
**Version Selection:**
|
||||
Configure webhook version through SDK initialization or Dashboard settings.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Guide
|
||||
|
||||
### 1. Set Up Webhook Endpoint
|
||||
|
||||
Create a public HTTPS endpoint to receive webhook events:
|
||||
|
||||
**Python (Flask):**
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/asleep-webhook', methods=['POST'])
|
||||
def asleep_webhook():
|
||||
# Verify authentication
|
||||
if not verify_webhook(request):
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
# Parse event
|
||||
event = request.json
|
||||
event_type = event.get('event')
|
||||
|
||||
# Route to appropriate handler
|
||||
if event_type == 'INFERENCE_COMPLETE':
|
||||
handle_inference_complete(event)
|
||||
elif event_type == 'SESSION_COMPLETE':
|
||||
handle_session_complete(event)
|
||||
|
||||
return jsonify({"status": "success"}), 200
|
||||
|
||||
def verify_webhook(request):
|
||||
api_key = request.headers.get('x-api-key')
|
||||
return api_key == EXPECTED_API_KEY
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=443, ssl_context='adhoc')
|
||||
```
|
||||
|
||||
**Node.js (Express):**
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/asleep-webhook', async (req, res) => {
|
||||
// Verify authentication
|
||||
if (!verifyWebhook(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { event } = req.body;
|
||||
|
||||
try {
|
||||
switch (event) {
|
||||
case 'INFERENCE_COMPLETE':
|
||||
await handleInferenceComplete(req.body);
|
||||
break;
|
||||
case 'SESSION_COMPLETE':
|
||||
await handleSessionComplete(req.body);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown event type: ${event}`);
|
||||
}
|
||||
|
||||
res.status(200).json({ status: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
res.status(500).json({ error: 'Processing failed' });
|
||||
}
|
||||
});
|
||||
|
||||
function verifyWebhook(req) {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
return apiKey === process.env.ASLEEP_API_KEY;
|
||||
}
|
||||
|
||||
// HTTPS server
|
||||
const options = {
|
||||
key: fs.readFileSync('private-key.pem'),
|
||||
cert: fs.readFileSync('certificate.pem')
|
||||
};
|
||||
|
||||
https.createServer(options, app).listen(443);
|
||||
```
|
||||
|
||||
### 2. Configure Webhook URL
|
||||
|
||||
Configure your webhook URL through:
|
||||
- SDK initialization (for mobile apps)
|
||||
- Asleep Dashboard (for backend integrations)
|
||||
|
||||
**SDK Example (Android):**
|
||||
```kotlin
|
||||
AsleepConfig.init(
|
||||
apiKey = "YOUR_API_KEY",
|
||||
userId = "user123",
|
||||
callbackUrl = "https://your-domain.com/asleep-webhook"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Handle Webhook Events
|
||||
|
||||
Implement handlers for each event type:
|
||||
|
||||
**Python Example:**
|
||||
```python
|
||||
def handle_inference_complete(event):
|
||||
"""Process incremental sleep data"""
|
||||
session_id = event['session_id']
|
||||
sleep_stages = event['sleep_stages']
|
||||
|
||||
# Update real-time dashboard
|
||||
redis_client.set(f"session:{session_id}:latest", json.dumps(sleep_stages))
|
||||
|
||||
# Notify connected clients via WebSocket
|
||||
websocket_broadcast(session_id, sleep_stages)
|
||||
|
||||
# Store for analysis
|
||||
db.incremental_data.insert_one(event)
|
||||
|
||||
def handle_session_complete(event):
|
||||
"""Process complete sleep report"""
|
||||
user_id = event['user_id']
|
||||
session_id = event['session_id']
|
||||
stat = event['stat']
|
||||
|
||||
# Store complete report
|
||||
db.sleep_reports.insert_one({
|
||||
'user_id': user_id,
|
||||
'session_id': session_id,
|
||||
'date': event['session']['start_time'],
|
||||
'statistics': stat,
|
||||
'created_at': datetime.now()
|
||||
})
|
||||
|
||||
# Update user's latest statistics
|
||||
update_user_stats(user_id)
|
||||
|
||||
# Send push notification
|
||||
send_notification(user_id, {
|
||||
'title': 'Sleep Report Ready',
|
||||
'body': f"Sleep time: {stat['sleep_time']}, Efficiency: {stat['sleep_efficiency']:.1f}%"
|
||||
})
|
||||
|
||||
# Trigger downstream processes
|
||||
calculate_weekly_trends(user_id)
|
||||
check_sleep_goals(user_id, stat)
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
Implement robust error handling:
|
||||
|
||||
**Retry Logic:**
|
||||
```python
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
||||
def process_webhook(event):
|
||||
"""Process webhook with automatic retry"""
|
||||
# Your processing logic here
|
||||
pass
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook_endpoint():
|
||||
try:
|
||||
event = request.json
|
||||
process_webhook(event)
|
||||
return jsonify({"status": "success"}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook processing failed: {e}")
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
```
|
||||
|
||||
**Idempotency:**
|
||||
```python
|
||||
def handle_session_complete(event):
|
||||
session_id = event['session_id']
|
||||
|
||||
# Check if already processed
|
||||
if db.processed_webhooks.find_one({'session_id': session_id}):
|
||||
logger.info(f"Session {session_id} already processed")
|
||||
return
|
||||
|
||||
# Process event
|
||||
save_sleep_report(event)
|
||||
|
||||
# Mark as processed
|
||||
db.processed_webhooks.insert_one({
|
||||
'session_id': session_id,
|
||||
'processed_at': datetime.now()
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Testing
|
||||
|
||||
Test webhook handling locally:
|
||||
|
||||
**ngrok for Local Testing:**
|
||||
```bash
|
||||
# Start your local server
|
||||
python app.py
|
||||
|
||||
# In another terminal, expose with ngrok
|
||||
ngrok http 5000
|
||||
|
||||
# Use the ngrok URL as your webhook URL
|
||||
# Example: https://abc123.ngrok.io/webhook
|
||||
```
|
||||
|
||||
**Mock Webhook Requests:**
|
||||
```bash
|
||||
# Test INFERENCE_COMPLETE
|
||||
curl -X POST http://localhost:5000/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-H "x-user-id: test_user" \
|
||||
-d '{
|
||||
"event": "INFERENCE_COMPLETE",
|
||||
"version": "V3",
|
||||
"session_id": "test123",
|
||||
"sleep_stages": [1, 1, 2]
|
||||
}'
|
||||
|
||||
# Test SESSION_COMPLETE
|
||||
curl -X POST http://localhost:5000/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-H "x-user-id: test_user" \
|
||||
-d '{
|
||||
"event": "SESSION_COMPLETE",
|
||||
"version": "V3",
|
||||
"session_id": "test123",
|
||||
"stat": {
|
||||
"sleep_time": "07:30:00",
|
||||
"sleep_efficiency": 88.5
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
- Always verify `x-api-key` header
|
||||
- Use HTTPS for webhook endpoints
|
||||
- Implement request signing if handling sensitive data
|
||||
- Rate limit webhook endpoint
|
||||
- Log all webhook attempts
|
||||
|
||||
### Reliability
|
||||
- Respond quickly (< 5 seconds ideal)
|
||||
- Process asynchronously if needed
|
||||
- Implement idempotency checks
|
||||
- Handle duplicate events gracefully
|
||||
- Return 2xx status even if processing fails (retry logic)
|
||||
|
||||
### Performance
|
||||
- Use message queues for heavy processing
|
||||
- Implement caching where appropriate
|
||||
- Batch database operations
|
||||
- Monitor webhook response times
|
||||
- Scale horizontally if needed
|
||||
|
||||
### Monitoring
|
||||
- Log all webhook events
|
||||
- Track processing success/failure rates
|
||||
- Monitor response times
|
||||
- Set up alerts for failures
|
||||
- Dashboard for webhook metrics
|
||||
|
||||
### Error Handling
|
||||
- Catch and log all exceptions
|
||||
- Return appropriate HTTP status codes
|
||||
- Implement exponential backoff
|
||||
- Dead letter queue for failed events
|
||||
- Manual review process for failures
|
||||
|
||||
---
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Real-Time Dashboard Updates
|
||||
|
||||
```python
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
event = request.json
|
||||
|
||||
if event['event'] == 'INFERENCE_COMPLETE':
|
||||
# Broadcast to connected WebSocket clients
|
||||
socketio.emit('sleep_update', {
|
||||
'session_id': event['session_id'],
|
||||
'sleep_stages': event['sleep_stages'],
|
||||
'timestamp': event['timestamp']
|
||||
}, room=event['user_id'])
|
||||
|
||||
return jsonify({"status": "success"}), 200
|
||||
```
|
||||
|
||||
### User Notifications
|
||||
|
||||
```python
|
||||
def handle_session_complete(event):
|
||||
user_id = event['user_id']
|
||||
stat = event['stat']
|
||||
|
||||
# Generate insights
|
||||
insights = generate_sleep_insights(stat)
|
||||
|
||||
# Send push notification
|
||||
send_push_notification(user_id, {
|
||||
'title': 'Your Sleep Report is Ready!',
|
||||
'body': f"You slept for {stat['sleep_time']} with {stat['sleep_efficiency']:.0f}% efficiency",
|
||||
'data': {
|
||||
'session_id': event['session_id'],
|
||||
'insights': insights
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Data Analytics Pipeline
|
||||
|
||||
```python
|
||||
def handle_session_complete(event):
|
||||
# Store in data warehouse
|
||||
bigquery_client.insert_rows_json('sleep_data.sessions', [{
|
||||
'user_id': event['user_id'],
|
||||
'session_id': event['session_id'],
|
||||
'date': event['session']['start_time'],
|
||||
'statistics': json.dumps(event['stat']),
|
||||
'ingested_at': datetime.now().isoformat()
|
||||
}])
|
||||
|
||||
# Trigger analytics jobs
|
||||
trigger_weekly_report_job(event['user_id'])
|
||||
update_cohort_analysis()
|
||||
```
|
||||
|
||||
### Integration with Other Systems
|
||||
|
||||
```python
|
||||
def handle_session_complete(event):
|
||||
user_id = event['user_id']
|
||||
stat = event['stat']
|
||||
|
||||
# Sync to Apple Health
|
||||
sync_to_apple_health(user_id, {
|
||||
'sleep_analysis': stat,
|
||||
'date': event['session']['start_time']
|
||||
})
|
||||
|
||||
# Update CRM
|
||||
update_crm_profile(user_id, {
|
||||
'last_sleep_date': event['session']['start_time'],
|
||||
'avg_sleep_efficiency': calculate_avg_efficiency(user_id)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Webhook Not Received
|
||||
|
||||
**Check:**
|
||||
- Endpoint is publicly accessible
|
||||
- HTTPS is properly configured
|
||||
- Firewall allows incoming requests
|
||||
- Webhook URL is correctly configured
|
||||
- Server is running and healthy
|
||||
|
||||
### Authentication Failures
|
||||
|
||||
**Check:**
|
||||
- `x-api-key` validation logic
|
||||
- API key matches dashboard
|
||||
- Headers are correctly parsed
|
||||
- Case sensitivity of header names
|
||||
|
||||
### Duplicate Events
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
def handle_webhook(event):
|
||||
event_id = f"{event['session_id']}:{event['event']}:{event['timestamp']}"
|
||||
|
||||
# Check if already processed
|
||||
if redis_client.exists(f"processed:{event_id}"):
|
||||
return
|
||||
|
||||
# Process event
|
||||
process_event(event)
|
||||
|
||||
# Mark as processed (expire after 24 hours)
|
||||
redis_client.setex(f"processed:{event_id}", 86400, "1")
|
||||
```
|
||||
|
||||
### Processing Delays
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
from celery import Celery
|
||||
|
||||
celery = Celery('tasks', broker='redis://localhost:6379')
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
event = request.json
|
||||
|
||||
# Queue for async processing
|
||||
process_webhook_async.delay(event)
|
||||
|
||||
# Respond immediately
|
||||
return jsonify({"status": "queued"}), 200
|
||||
|
||||
@celery.task
|
||||
def process_webhook_async(event):
|
||||
# Heavy processing here
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Official Documentation**: https://docs-en.asleep.ai/docs/webhook.md
|
||||
- **API Basics**: https://docs-en.asleep.ai/docs/api-basics.md
|
||||
- **Dashboard**: https://dashboard.asleep.ai
|
||||
485
skills/sleeptrack-foundation/SKILL.md
Normal file
485
skills/sleeptrack-foundation/SKILL.md
Normal file
@@ -0,0 +1,485 @@
|
||||
---
|
||||
name: sleeptrack-foundation
|
||||
description: This skill provides foundational knowledge about Asleep sleep tracking platform, covering core concepts, authentication, data structures, error handling, and platform-agnostic best practices. Use this skill when developers ask about Asleep fundamentals, API concepts, error codes, sleep data structures, or need to understand how the platform works before implementing platform-specific integration. This skill serves as prerequisite knowledge for sleeptrack-ios, sleeptrack-android, and sleeptrack-be skills.
|
||||
---
|
||||
|
||||
# Sleeptrack Foundation
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides essential foundational knowledge for integrating the Asleep sleep tracking platform. It covers core concepts, authentication patterns, data structures, error handling, and platform-agnostic best practices that apply across all implementation approaches (iOS, Android, and backend API).
|
||||
|
||||
Use this skill when developers need to understand:
|
||||
- What Asleep is and how sleep tracking works
|
||||
- API authentication and key management
|
||||
- Sleep session concepts and lifecycle
|
||||
- Data structures (Sessions, Reports, Statistics)
|
||||
- Error codes and troubleshooting
|
||||
- Platform-agnostic integration patterns
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### What is Asleep?
|
||||
|
||||
Asleep is a sleep tracking platform that analyzes sleep using audio-based monitoring through device microphones. The platform provides:
|
||||
|
||||
- **Real-time sleep stage analysis**: Wake, Light, Deep, REM detection
|
||||
- **Comprehensive sleep metrics**: Efficiency, latency, total sleep time, wake after sleep onset
|
||||
- **Snoring detection and analysis**: Snoring stages and patterns
|
||||
- **Multi-platform SDKs**: Native iOS and Android SDKs plus REST API
|
||||
- **Dashboard analytics**: Web-based analytics and user management
|
||||
|
||||
### User Management
|
||||
|
||||
Each application user must be registered with Asleep before tracking sleep.
|
||||
|
||||
**Key Points**:
|
||||
- User ID is managed by the host application (not generated by Asleep)
|
||||
- One user can have multiple sleep sessions
|
||||
- User data persists across sessions for trend analysis
|
||||
- Users can be created, retrieved, updated, and deleted via API
|
||||
|
||||
**Example User ID Schemes**:
|
||||
```
|
||||
- UUID: "550e8400-e29b-41d4-a716-446655440000"
|
||||
- Email-based: "user@example.com"
|
||||
- App-specific: "app_user_12345"
|
||||
```
|
||||
|
||||
### Sleep Sessions
|
||||
|
||||
A session represents one complete sleep tracking period from start to stop.
|
||||
|
||||
**Session Lifecycle States**:
|
||||
1. **IDLE**: No tracking in progress
|
||||
2. **INITIALIZING**: SDK preparing resources
|
||||
3. **INITIALIZED**: Ready to start tracking
|
||||
4. **TRACKING_STARTED**: Active tracking in progress
|
||||
5. **TRACKING_STOPPING**: Ending session and uploading data
|
||||
|
||||
**Session Requirements**:
|
||||
- Minimum tracking duration: 5 minutes for valid session
|
||||
- Microphone access required throughout tracking
|
||||
- Network connectivity needed for data upload
|
||||
- One active session per user at a time
|
||||
|
||||
**Real-time Data Access**:
|
||||
- Available after sequence 10
|
||||
- Check every 10 sequences thereafter
|
||||
- Provides preliminary sleep stage data during tracking
|
||||
|
||||
### Sleep Reports
|
||||
|
||||
Reports contain comprehensive analysis of completed sleep sessions.
|
||||
|
||||
**Report Structure**:
|
||||
```
|
||||
Report
|
||||
├── Session Metadata
|
||||
│ ├── session_id
|
||||
│ ├── user_id
|
||||
│ ├── start_time
|
||||
│ └── end_time
|
||||
├── Sleep Stages Timeline
|
||||
│ ├── Wake periods
|
||||
│ ├── Light sleep periods
|
||||
│ ├── Deep sleep periods
|
||||
│ └── REM sleep periods
|
||||
├── Sleep Statistics
|
||||
│ ├── Total sleep time
|
||||
│ ├── Time in bed
|
||||
│ ├── Sleep efficiency (%)
|
||||
│ ├── Sleep latency (time to fall asleep)
|
||||
│ ├── Wake after sleep onset (WASO)
|
||||
│ ├── Sleep stage durations
|
||||
│ └── Sleep stage ratios
|
||||
└── Snoring Analysis
|
||||
├── Snoring detected (yes/no)
|
||||
├── Snoring stages timeline
|
||||
└── Snoring statistics
|
||||
```
|
||||
|
||||
**Key Metrics Explained**:
|
||||
|
||||
- **Sleep Efficiency**: (Total sleep time / Time in bed) × 100%
|
||||
- Good: > 85%
|
||||
- Fair: 75-85%
|
||||
- Poor: < 75%
|
||||
|
||||
- **Sleep Latency**: Time from lying down to falling asleep
|
||||
- Normal: 10-20 minutes
|
||||
- Fast: < 10 minutes (may indicate sleep deprivation)
|
||||
- Slow: > 20 minutes (may indicate insomnia)
|
||||
|
||||
- **WASO**: Total wake time after initial sleep onset
|
||||
- Lower is better (indicates fewer disruptions)
|
||||
|
||||
### Statistics
|
||||
|
||||
Aggregated metrics across multiple sessions for trend analysis.
|
||||
|
||||
**Available Statistics**:
|
||||
- Average sleep duration over time range
|
||||
- Average sleep efficiency
|
||||
- Sleep stage distribution averages
|
||||
- Trends and patterns
|
||||
|
||||
**Statistics API**:
|
||||
```
|
||||
GET /users/{user_id}/statistics/average?from={date}&to={date}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### API Key Management
|
||||
|
||||
All Asleep integrations require an API key for authentication.
|
||||
|
||||
**Obtaining an API Key**:
|
||||
1. Sign up at https://dashboard.asleep.ai
|
||||
2. Navigate to API key generation section
|
||||
3. Create API key for the application
|
||||
4. Store securely (never commit to version control)
|
||||
|
||||
**Using API Keys**:
|
||||
|
||||
For SDK Integration (iOS/Android):
|
||||
```kotlin
|
||||
// Android
|
||||
AsleepConfig.init(
|
||||
apiKey = "your_api_key_here",
|
||||
userId = "user_unique_id",
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
```swift
|
||||
// iOS
|
||||
AsleepConfig.init(
|
||||
apiKey: "your_api_key_here",
|
||||
userId: "user_unique_id",
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
For REST API Integration:
|
||||
```http
|
||||
GET /sessions/{session_id}
|
||||
X-API-Key: your_api_key_here
|
||||
```
|
||||
|
||||
**Security Best Practices**:
|
||||
- Store API keys in environment variables or secure storage
|
||||
- Never hardcode keys in source code
|
||||
- Use different keys for development and production
|
||||
- Rotate keys periodically
|
||||
- Monitor usage in Dashboard to detect unauthorized access
|
||||
- Revoke compromised keys immediately
|
||||
|
||||
## Error Handling
|
||||
|
||||
Understanding error codes is critical for robust integration.
|
||||
|
||||
### Error Categories
|
||||
|
||||
**Critical Errors** (must stop tracking):
|
||||
These errors indicate conditions that prevent continued tracking and require user intervention or code fixes.
|
||||
|
||||
**Warning Errors** (can continue tracking):
|
||||
These are transient issues that the SDK handles automatically while tracking continues.
|
||||
|
||||
### Error Code Reference
|
||||
|
||||
#### Critical Errors
|
||||
|
||||
**ERR_MIC_PERMISSION**
|
||||
- **Cause**: App lacks microphone access permission
|
||||
- **Action**: Request microphone permission from user
|
||||
- **Platform Notes**:
|
||||
- Android: Check RECORD_AUDIO permission
|
||||
- iOS: Check NSMicrophoneUsageDescription and authorization status
|
||||
|
||||
**ERR_AUDIO**
|
||||
- **Cause**: Microphone unavailable or in use by another app
|
||||
- **Action**:
|
||||
- Close conflicting apps using microphone
|
||||
- Check microphone hardware functionality
|
||||
- Verify no audio conflicts in device settings
|
||||
|
||||
**ERR_INVALID_URL**
|
||||
- **Cause**: Malformed API endpoint URL in configuration
|
||||
- **Action**: Verify AsleepConfig URL format
|
||||
- **Example Fix**: Ensure base URL is valid HTTPS endpoint
|
||||
|
||||
**ERR_COMMON_EXPIRED**
|
||||
- **Cause**: API rate limit exceeded or subscription plan expired
|
||||
- **Action**:
|
||||
- Check Dashboard for plan status
|
||||
- Review API usage patterns
|
||||
- Upgrade plan if needed
|
||||
- Implement rate limiting in application
|
||||
|
||||
**ERR_UPLOAD_FORBIDDEN**
|
||||
- **Cause**: Multiple simultaneous tracking attempts with same user_id
|
||||
- **Action**:
|
||||
- Ensure only one device tracks per user at a time
|
||||
- Check for orphaned sessions
|
||||
- Implement proper session cleanup
|
||||
|
||||
**ERR_UPLOAD_NOT_FOUND** / **ERR_CLOSE_NOT_FOUND**
|
||||
- **Cause**: Attempting to interact with non-existent or already-ended session
|
||||
- **Action**:
|
||||
- Verify session exists before operations
|
||||
- Handle session expiration properly
|
||||
- Implement session state management
|
||||
|
||||
#### Warning Errors
|
||||
|
||||
**ERR_AUDIO_SILENCED**
|
||||
- **Cause**: Audio temporarily unavailable but tracking continues
|
||||
- **Impact**: Minimal, SDK handles gracefully
|
||||
- **Action**: Log for monitoring, tracking continues
|
||||
|
||||
**ERR_AUDIO_UNSILENCED**
|
||||
- **Cause**: Audio restored after silence period
|
||||
- **Impact**: Tracking resumes normally
|
||||
- **Action**: Log for monitoring
|
||||
|
||||
**ERR_UPLOAD_FAILED**
|
||||
- **Cause**: Network connectivity issue during data upload
|
||||
- **Impact**: SDK will retry automatically
|
||||
- **Action**:
|
||||
- Verify network connectivity
|
||||
- Check for firewall/proxy issues
|
||||
- Monitor retry success
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
**Distinguish Error Severity**:
|
||||
```kotlin
|
||||
// Android example
|
||||
when (errorCode) {
|
||||
in criticalErrors -> {
|
||||
// Stop tracking, notify user
|
||||
stopTracking()
|
||||
showErrorDialog(errorCode)
|
||||
}
|
||||
in warningErrors -> {
|
||||
// Log and continue
|
||||
logWarning(errorCode)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Provide User-Friendly Messages**:
|
||||
```kotlin
|
||||
fun getUserFriendlyMessage(errorCode: AsleepErrorCode): String {
|
||||
return when (errorCode) {
|
||||
ERR_MIC_PERMISSION -> "Please allow microphone access to track sleep"
|
||||
ERR_AUDIO -> "Another app is using the microphone. Please close it and try again"
|
||||
ERR_COMMON_EXPIRED -> "Your subscription has expired. Please renew to continue tracking"
|
||||
// ... more mappings
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implement Retry Logic**:
|
||||
```kotlin
|
||||
// For network-related errors
|
||||
suspend fun uploadWithRetry(maxRetries: Int = 3) {
|
||||
repeat(maxRetries) { attempt ->
|
||||
try {
|
||||
upload()
|
||||
return
|
||||
} catch (e: NetworkException) {
|
||||
if (attempt == maxRetries - 1) throw e
|
||||
delay(2.0.pow(attempt) * 1000) // Exponential backoff
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Plan Considerations
|
||||
|
||||
Asleep operates on different data plans that affect API usage.
|
||||
|
||||
**Plan Limits**:
|
||||
- Request rate limits
|
||||
- Total sessions per month
|
||||
- Data retention periods
|
||||
- Feature availability
|
||||
|
||||
**Monitoring Usage**:
|
||||
- Check Dashboard regularly
|
||||
- Implement usage tracking in application
|
||||
- Set up alerts for approaching limits
|
||||
- Plan capacity for user growth
|
||||
|
||||
## Integration Workflows
|
||||
|
||||
### Typical Integration Flow
|
||||
|
||||
1. **Setup Phase**:
|
||||
- Obtain API key from Dashboard
|
||||
- Install SDK (iOS/Android) or configure REST API client
|
||||
- Configure authentication
|
||||
|
||||
2. **User Registration**:
|
||||
- Create user in Asleep system
|
||||
- Store user_id mapping in application
|
||||
|
||||
3. **Sleep Tracking Session**:
|
||||
- Request necessary permissions
|
||||
- Initialize SDK with user credentials
|
||||
- Start tracking session
|
||||
- Monitor session state
|
||||
- Handle real-time data (optional)
|
||||
- Stop tracking session
|
||||
|
||||
4. **Report Generation**:
|
||||
- Wait for report processing (automatic)
|
||||
- Fetch completed report
|
||||
- Display sleep analysis to user
|
||||
|
||||
5. **Statistics & Trends**:
|
||||
- Query historical sessions
|
||||
- Calculate aggregated statistics
|
||||
- Display trends over time
|
||||
|
||||
### Common Integration Scenarios
|
||||
|
||||
**Scenario 1: First-Time User Setup**
|
||||
```
|
||||
User downloads app
|
||||
→ Request microphone permission
|
||||
→ Create Asleep user (POST /users)
|
||||
→ Initialize SDK with API key + user_id
|
||||
→ Guide user through first tracking session
|
||||
```
|
||||
|
||||
**Scenario 2: Returning User**
|
||||
```
|
||||
User opens app
|
||||
→ Load user_id from local storage
|
||||
→ Initialize SDK with credentials
|
||||
→ Check for existing running session (reconnect if found)
|
||||
→ Display previous sleep reports
|
||||
```
|
||||
|
||||
**Scenario 3: Background Tracking**
|
||||
```
|
||||
User starts tracking before sleep
|
||||
→ Start foreground service (Android) / background mode (iOS)
|
||||
→ Maintain microphone access
|
||||
→ Handle app lifecycle events
|
||||
→ Continue tracking through sleep
|
||||
→ Stop tracking in morning
|
||||
→ Process and display report
|
||||
```
|
||||
|
||||
## Platform Selection Guide
|
||||
|
||||
Choose the appropriate integration approach based on application type:
|
||||
|
||||
**Use iOS SDK (sleeptrack-ios)**:
|
||||
- Native iOS application
|
||||
- Need deep iOS integration (Siri, HealthKit, etc.)
|
||||
- Require iOS-specific UI patterns
|
||||
- Swift/SwiftUI development
|
||||
|
||||
**Use Android SDK (sleeptrack-android)**:
|
||||
- Native Android application
|
||||
- Need Android-specific features (foreground service, etc.)
|
||||
- Require Android UI patterns
|
||||
- Kotlin/Jetpack Compose development
|
||||
|
||||
**Use REST API (sleeptrack-be)**:
|
||||
- Backend/server-side integration
|
||||
- Multi-platform web application
|
||||
- Data aggregation and analytics
|
||||
- Webhook-based event processing
|
||||
- Cross-platform mobile framework (React Native, Flutter) with custom bridge
|
||||
|
||||
## Resources
|
||||
|
||||
### Official Documentation
|
||||
|
||||
- **Main Documentation**: https://docs-en.asleep.ai
|
||||
- **LLM-Optimized Reference**: https://docs-en.asleep.ai/llms.txt
|
||||
- **Dashboard**: https://dashboard.asleep.ai
|
||||
|
||||
### Key Documentation Pages
|
||||
|
||||
- **QuickStart Guide**: https://docs-en.asleep.ai/docs/quickstart.md
|
||||
- **Sleep Data Overview**: https://docs-en.asleep.ai/docs/sleep-data.md
|
||||
- **System Overview**: https://docs-en.asleep.ai/docs/system-overview.md
|
||||
- **API Basics**: https://docs-en.asleep.ai/docs/api-basics.md
|
||||
- **Webhook Guide**: https://docs-en.asleep.ai/docs/webhook.md
|
||||
- **Sleep Environment Guidelines**: https://docs-en.asleep.ai/docs/sleep-environment-guideline.md
|
||||
|
||||
### Platform-Specific Documentation
|
||||
|
||||
**Android**:
|
||||
- Get Started: https://docs-en.asleep.ai/docs/android-get-started.md
|
||||
- Error Codes: https://docs-en.asleep.ai/docs/android-error-codes.md
|
||||
- AsleepConfig: https://docs-en.asleep.ai/docs/android-asleep-config.md
|
||||
- SleepTrackingManager: https://docs-en.asleep.ai/docs/android-sleep-tracking-manager.md
|
||||
|
||||
**iOS**:
|
||||
- Get Started: https://docs-en.asleep.ai/docs/ios-get-started.md
|
||||
- Error Codes: https://docs-en.asleep.ai/docs/ios-error-codes.md
|
||||
- AsleepConfig: https://docs-en.asleep.ai/docs/ios-asleep-config.md
|
||||
- SleepTrackingManager: https://docs-en.asleep.ai/docs/ios-sleep-tracking-manager.md
|
||||
|
||||
### Reference Files
|
||||
|
||||
This skill includes detailed API reference documentation:
|
||||
|
||||
- `references/asleep_api_reference.md`: Comprehensive API endpoint reference, data structures, and integration patterns
|
||||
|
||||
To load this reference for detailed API information:
|
||||
```
|
||||
Read references/asleep_api_reference.md
|
||||
```
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Security
|
||||
- Never expose API keys in client code
|
||||
- Implement secure storage for credentials
|
||||
- Use HTTPS for all API communications
|
||||
- Validate user permissions before operations
|
||||
|
||||
### Performance
|
||||
- Cache reports when appropriate
|
||||
- Batch API requests to respect rate limits
|
||||
- Implement efficient session state management
|
||||
- Monitor real-time data access patterns
|
||||
|
||||
### User Experience
|
||||
- Provide clear permission rationales
|
||||
- Show friendly error messages
|
||||
- Display progress during tracking
|
||||
- Handle app lifecycle gracefully
|
||||
|
||||
### Reliability
|
||||
- Implement comprehensive error handling
|
||||
- Add retry logic for transient failures
|
||||
- Log errors for debugging
|
||||
- Test edge cases (interruptions, low battery, etc.)
|
||||
|
||||
### Data Management
|
||||
- Clean up old sessions appropriately
|
||||
- Respect user privacy and data retention
|
||||
- Implement proper user deletion flows
|
||||
- Backup critical session data
|
||||
|
||||
## Next Steps
|
||||
|
||||
After understanding these foundational concepts, proceed to platform-specific skills:
|
||||
|
||||
- **iOS Development**: Use `sleeptrack-ios` skill
|
||||
- **Android Development**: Use `sleeptrack-android` skill
|
||||
- **Backend API Integration**: Use `sleeptrack-be` skill
|
||||
|
||||
Each platform-specific skill builds on this foundation with implementation details, code examples, and platform-specific patterns.
|
||||
259
skills/sleeptrack-foundation/references/asleep_api_reference.md
Normal file
259
skills/sleeptrack-foundation/references/asleep_api_reference.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Asleep API Reference
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Main Documentation**: https://docs-en.asleep.ai
|
||||
- **LLM-Optimized Docs**: https://docs-en.asleep.ai/llms.txt
|
||||
- **Dashboard**: https://dashboard.asleep.ai
|
||||
|
||||
## API Authentication
|
||||
|
||||
All Asleep API requests require authentication using an API key.
|
||||
|
||||
### Getting an API Key
|
||||
|
||||
1. Sign up for the Asleep Dashboard at https://dashboard.asleep.ai
|
||||
2. Navigate to the API key generation section
|
||||
3. Generate your API key for your application
|
||||
4. Store the API key securely (never commit to version control)
|
||||
|
||||
### Authentication Method
|
||||
|
||||
Include the API key in request headers:
|
||||
```
|
||||
X-API-Key: your_api_key_here
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### User Management
|
||||
|
||||
Each application user must be registered with Asleep before tracking sleep data.
|
||||
|
||||
- **User ID**: Unique identifier for each user (managed by your application)
|
||||
- User data is associated with sessions and reports
|
||||
- Users can have multiple sleep tracking sessions
|
||||
|
||||
### Sleep Sessions
|
||||
|
||||
A sleep session represents one tracking period from start to stop.
|
||||
|
||||
**Session States**:
|
||||
- IDLE: Not tracking
|
||||
- INITIALIZING: Preparing to track
|
||||
- INITIALIZED: Ready to track
|
||||
- TRACKING_STARTED: Active tracking in progress
|
||||
- TRACKING_STOPPING: Ending tracking session
|
||||
|
||||
**Key Session Characteristics**:
|
||||
- Minimum tracking time: 5 minutes for valid session
|
||||
- Real-time data available after sequence 10, then every 10 sequences
|
||||
- Each session generates a comprehensive sleep report
|
||||
|
||||
### Sleep Reports
|
||||
|
||||
Reports contain detailed analysis of a completed sleep session.
|
||||
|
||||
**Core Metrics**:
|
||||
- Sleep stages: Wake, Light, Deep, REM
|
||||
- Sleep efficiency: Percentage of time actually sleeping
|
||||
- Sleep latency: Time to fall asleep
|
||||
- Total sleep time
|
||||
- Time in bed
|
||||
- Wake after sleep onset (WASO)
|
||||
- Sleep stage ratios and durations
|
||||
- Snoring detection and analysis
|
||||
|
||||
### Statistics
|
||||
|
||||
Aggregated sleep metrics across multiple sessions.
|
||||
|
||||
**Available Statistics**:
|
||||
- Average sleep duration
|
||||
- Average sleep efficiency
|
||||
- Sleep stage distribution
|
||||
- Trends over time periods
|
||||
|
||||
## Error Codes
|
||||
|
||||
### Critical Errors (Stop Tracking)
|
||||
|
||||
- **ERR_MIC_PERMISSION**: Microphone access permission denied
|
||||
- **ERR_AUDIO**: Microphone in use by another app or hardware issue
|
||||
- **ERR_INVALID_URL**: Malformed API endpoint URL
|
||||
- **ERR_COMMON_EXPIRED**: API rate limit exceeded or plan expired
|
||||
- **ERR_UPLOAD_FORBIDDEN**: Multiple simultaneous tracking attempts with same user ID
|
||||
- **ERR_UPLOAD_NOT_FOUND**: Session does not exist or already ended
|
||||
- **ERR_CLOSE_NOT_FOUND**: Attempted to close non-existent session
|
||||
|
||||
### Warning Errors (Continue Tracking)
|
||||
|
||||
- **ERR_AUDIO_SILENCED**: Audio temporarily unavailable but tracking continues
|
||||
- **ERR_AUDIO_UNSILENCED**: Audio restored after silence
|
||||
- **ERR_UPLOAD_FAILED**: Network issue during upload, will retry
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
### User Management
|
||||
|
||||
**Create User**
|
||||
```
|
||||
POST /users
|
||||
Body: { "user_id": "string" }
|
||||
```
|
||||
|
||||
**Get User**
|
||||
```
|
||||
GET /users/{user_id}
|
||||
```
|
||||
|
||||
**Update User**
|
||||
```
|
||||
PUT /users/{user_id}
|
||||
Body: { /* user properties */ }
|
||||
```
|
||||
|
||||
**Delete User**
|
||||
```
|
||||
DELETE /users/{user_id}
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
**Get Session**
|
||||
```
|
||||
GET /sessions/{session_id}
|
||||
```
|
||||
|
||||
**List Sessions**
|
||||
```
|
||||
GET /sessions?user_id={user_id}&from={date}&to={date}
|
||||
```
|
||||
|
||||
**Delete Session**
|
||||
```
|
||||
DELETE /sessions/{session_id}
|
||||
```
|
||||
|
||||
### Statistics
|
||||
|
||||
**Get Average Stats**
|
||||
```
|
||||
GET /users/{user_id}/statistics/average?from={date}&to={date}
|
||||
```
|
||||
|
||||
## Webhooks
|
||||
|
||||
Asleep supports webhooks for real-time event notifications.
|
||||
|
||||
**Supported Events**:
|
||||
- Session started
|
||||
- Session completed
|
||||
- Report generated
|
||||
- User created/updated/deleted
|
||||
|
||||
**Webhook Configuration**:
|
||||
Configure webhook URLs in the Asleep Dashboard.
|
||||
|
||||
## Platform SDKs
|
||||
|
||||
### Android SDK
|
||||
|
||||
- **Language**: Kotlin
|
||||
- **Minimum SDK**: Check latest documentation
|
||||
- **Key Classes**: AsleepConfig, SleepTrackingManager
|
||||
- **Architecture**: MVVM patterns, Hilt dependency injection
|
||||
- **Permissions Required**:
|
||||
- RECORD_AUDIO (microphone)
|
||||
- POST_NOTIFICATIONS
|
||||
- REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
|
||||
- FOREGROUND_SERVICE
|
||||
|
||||
### iOS SDK
|
||||
|
||||
- **Language**: Swift
|
||||
- **Minimum iOS Version**: Check latest documentation
|
||||
- **Key Classes**: AsleepConfig, SleepTrackingManager
|
||||
- **Architecture**: Delegate patterns, Combine framework
|
||||
- **Permissions Required**:
|
||||
- Microphone access (NSMicrophoneUsageDescription)
|
||||
- Notifications
|
||||
- Background modes
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Session
|
||||
|
||||
Represents a sleep tracking session with metadata and tracking state information.
|
||||
|
||||
### Report
|
||||
|
||||
Comprehensive sleep analysis including:
|
||||
- Sleep stages timeline
|
||||
- Statistical metrics
|
||||
- Snoring analysis
|
||||
- Quality indicators
|
||||
|
||||
### Statistics
|
||||
|
||||
Aggregated metrics across multiple sessions for trend analysis.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### API Key Security
|
||||
|
||||
- Never hardcode API keys in source code
|
||||
- Use environment variables or secure storage
|
||||
- Rotate keys periodically
|
||||
- Monitor usage in Dashboard
|
||||
|
||||
### User ID Management
|
||||
|
||||
- Use consistent user IDs across sessions
|
||||
- Consider user privacy in ID scheme
|
||||
- Implement proper user consent flows
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Distinguish between critical errors (stop tracking) and warnings (continue)
|
||||
- Provide user-friendly error messages
|
||||
- Implement retry logic for network failures
|
||||
- Log errors for debugging
|
||||
|
||||
### Session Management
|
||||
|
||||
- Validate minimum tracking time (5 minutes)
|
||||
- Handle app lifecycle properly (don't lose sessions)
|
||||
- Implement reconnection logic for interrupted sessions
|
||||
- Clean up resources when stopping tracking
|
||||
|
||||
### Performance
|
||||
|
||||
- Check real-time data appropriately (after sequence 10, every 10 sequences)
|
||||
- Cache reports when appropriate
|
||||
- Batch API requests when possible
|
||||
- Monitor API rate limits
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Health & Fitness Apps
|
||||
|
||||
Integrate sleep tracking alongside activity tracking for comprehensive health insights.
|
||||
|
||||
### Healthcare & Wellness
|
||||
|
||||
Clinical-grade sleep monitoring for patient care and wellness programs.
|
||||
|
||||
### Sleep Tech
|
||||
|
||||
Dedicated sleep improvement applications with detailed analysis and recommendations.
|
||||
|
||||
### Smart Home & IoT
|
||||
|
||||
Integrate sleep data with smart home automation for optimized sleep environment.
|
||||
|
||||
## Support & Resources
|
||||
|
||||
- **Documentation**: https://docs-en.asleep.ai
|
||||
- **Dashboard**: https://dashboard.asleep.ai
|
||||
- **Email**: Contact through dashboard for technical support
|
||||
526
skills/sleeptrack-ios/SKILL.md
Normal file
526
skills/sleeptrack-ios/SKILL.md
Normal file
@@ -0,0 +1,526 @@
|
||||
---
|
||||
name: sleeptrack-ios
|
||||
description: This skill helps iOS developers integrate the Asleep SDK for sleep tracking functionality. Use this skill when building native iOS apps with Swift/SwiftUI that need sleep tracking capabilities, implementing delegate patterns, configuring iOS permissions (microphone, notifications, background modes), managing tracking lifecycle, integrating Siri Shortcuts, or working with Combine framework for reactive state management.
|
||||
---
|
||||
|
||||
# Sleeptrack iOS
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides comprehensive guidance for integrating the Asleep SDK into native iOS applications using Swift and SwiftUI. It covers SDK setup, iOS-specific permissions, delegate-based architecture, tracking lifecycle management, Combine framework integration, and Siri Shortcuts support.
|
||||
|
||||
Use this skill when:
|
||||
- Building native iOS sleep tracking applications
|
||||
- Implementing SwiftUI-based tracking interfaces
|
||||
- Managing iOS permissions and background modes
|
||||
- Working with delegate patterns for SDK callbacks
|
||||
- Integrating Siri Shortcuts for voice-activated tracking
|
||||
- Using Combine framework for reactive state management
|
||||
|
||||
**Prerequisites**: Developers should first review the `sleeptrack-foundation` skill to understand core Asleep concepts, authentication, data structures, and error handling before implementing iOS-specific integration.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Installation
|
||||
|
||||
Add AsleepSDK to your Xcode project using Swift Package Manager:
|
||||
|
||||
```swift
|
||||
// In Xcode: File → Add Packages
|
||||
// Enter package URL: https://github.com/asleep-ai/asleep-sdk-ios
|
||||
```
|
||||
|
||||
Or add to `Package.swift`:
|
||||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/asleep-ai/asleep-sdk-ios", from: "2.0.0")
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Configure iOS Permissions
|
||||
|
||||
Add required permissions to `Info.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Microphone access for audio-based sleep tracking -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app uses your microphone to track sleep stages and detect snoring during sleep.</string>
|
||||
|
||||
<!-- Background audio mode for continuous tracking -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
|
||||
<!-- Optional: For notification reminders -->
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>Get reminders to start and stop sleep tracking.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### 3. Basic Setup
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import AsleepSDK
|
||||
|
||||
@main
|
||||
struct SleepTrackerApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SDK Architecture
|
||||
|
||||
The Asleep iOS SDK follows a delegate-based architecture with three main components:
|
||||
|
||||
### 1. AsleepConfig - Configuration and User Management
|
||||
|
||||
**Purpose**: Initialize SDK with API credentials and manage user lifecycle.
|
||||
|
||||
**Key Delegate**: `AsleepConfigDelegate`
|
||||
|
||||
```swift
|
||||
protocol AsleepConfigDelegate {
|
||||
func userDidJoin(userId: String, config: Asleep.Config)
|
||||
func didFailUserJoin(error: Asleep.AsleepError)
|
||||
func userDidDelete(userId: String)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. SleepTrackingManager - Tracking Lifecycle
|
||||
|
||||
**Purpose**: Control sleep tracking start, stop, and monitor session state.
|
||||
|
||||
**Key Delegate**: `AsleepSleepTrackingManagerDelegate`
|
||||
|
||||
```swift
|
||||
protocol AsleepSleepTrackingManagerDelegate {
|
||||
func didCreate() // Session created
|
||||
func didUpload(sequence: Int) // Data uploaded
|
||||
func didClose(sessionId: String) // Tracking stopped
|
||||
func didFail(error: Asleep.AsleepError) // Error occurred
|
||||
func didInterrupt() // Interrupted (e.g., phone call)
|
||||
func didResume() // Resumed after interruption
|
||||
func micPermissionWasDenied() // Mic permission denied
|
||||
func analysing(session: Asleep.Model.Session) // Real-time data (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Reports - Retrieving Sleep Data
|
||||
|
||||
**Purpose**: Fetch sleep reports and session lists after tracking completes.
|
||||
|
||||
```swift
|
||||
// Reports API is async/await based, not delegate-driven
|
||||
let reports = Asleep.createReports(config: config)
|
||||
|
||||
// Get single report
|
||||
let report = try await reports.report(sessionId: "session_id")
|
||||
|
||||
// Get report list
|
||||
let reportList = try await reports.reports(
|
||||
fromDate: "2024-01-01",
|
||||
toDate: "2024-01-31"
|
||||
)
|
||||
```
|
||||
|
||||
## Implementation Overview
|
||||
|
||||
### Minimal ViewModel Example
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import Combine
|
||||
import AsleepSDK
|
||||
|
||||
final class SleepTrackingViewModel: ObservableObject {
|
||||
private(set) var trackingManager: Asleep.SleepTrackingManager?
|
||||
private(set) var reports: Asleep.Reports?
|
||||
|
||||
@Published var isTracking = false
|
||||
@Published var error: String?
|
||||
@Published private(set) var config: Asleep.Config?
|
||||
|
||||
func initAsleepConfig(apiKey: String, userId: String) {
|
||||
Asleep.initAsleepConfig(
|
||||
apiKey: apiKey,
|
||||
userId: userId,
|
||||
delegate: self
|
||||
)
|
||||
}
|
||||
|
||||
func startTracking() {
|
||||
trackingManager?.startTracking()
|
||||
}
|
||||
|
||||
func stopTracking() {
|
||||
trackingManager?.stopTracking()
|
||||
}
|
||||
}
|
||||
|
||||
// Implement delegates
|
||||
extension SleepTrackingViewModel: AsleepConfigDelegate {
|
||||
func userDidJoin(userId: String, config: Asleep.Config) {
|
||||
Task { @MainActor in
|
||||
self.config = config
|
||||
self.trackingManager = Asleep.createSleepTrackingManager(
|
||||
config: config,
|
||||
delegate: self
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func didFailUserJoin(error: Asleep.AsleepError) {
|
||||
Task { @MainActor in
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func userDidDelete(userId: String) {
|
||||
// Handle user deletion
|
||||
}
|
||||
}
|
||||
|
||||
extension SleepTrackingViewModel: AsleepSleepTrackingManagerDelegate {
|
||||
func didCreate() {
|
||||
Task { @MainActor in
|
||||
self.isTracking = true
|
||||
}
|
||||
}
|
||||
|
||||
func didClose(sessionId: String) {
|
||||
Task { @MainActor in
|
||||
self.isTracking = false
|
||||
// Initialize reports to fetch session data
|
||||
self.reports = Asleep.createReports(config: config!)
|
||||
}
|
||||
}
|
||||
|
||||
func didFail(error: Asleep.AsleepError) {
|
||||
Task { @MainActor in
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// Implement other delegate methods as needed
|
||||
}
|
||||
```
|
||||
|
||||
For complete ViewModel implementation with all delegate methods, see [references/complete_viewmodel_implementation.md](references/complete_viewmodel_implementation.md)
|
||||
|
||||
## iOS-Specific Features
|
||||
|
||||
### 1. Siri Shortcuts
|
||||
|
||||
Enable voice-activated tracking with App Intents (iOS 16+). Users can say "Hey Siri, start sleep" or "Hey Siri, stop sleep".
|
||||
|
||||
For complete Siri Shortcuts implementation, see [references/ios_specific_features.md](references/ios_specific_features.md#siri-shortcuts-integration)
|
||||
|
||||
### 2. Background Audio Mode
|
||||
|
||||
Configure background audio to maintain tracking during sleep. Simply add `audio` to `UIBackgroundModes` in Info.plist - iOS handles the rest automatically.
|
||||
|
||||
For details, see [references/ios_specific_features.md](references/ios_specific_features.md#background-audio-mode)
|
||||
|
||||
### 3. Microphone Permission
|
||||
|
||||
Request microphone permission before starting tracking:
|
||||
|
||||
```swift
|
||||
import AVFoundation
|
||||
|
||||
func requestMicrophonePermission() async -> Bool {
|
||||
switch AVAudioSession.sharedInstance().recordPermission {
|
||||
case .granted: return true
|
||||
case .denied: return false
|
||||
case .undetermined:
|
||||
return await AVAudioSession.sharedInstance().requestRecordPermission()
|
||||
@unknown default: return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For complete permission handling, see [references/ios_specific_features.md](references/ios_specific_features.md#microphone-permission-handling)
|
||||
|
||||
### 4. App Lifecycle Management
|
||||
|
||||
Handle app state transitions using SwiftUI's `scenePhase`:
|
||||
|
||||
```swift
|
||||
struct SleepTrackingView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
// ... view content ...
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
switch newPhase {
|
||||
case .active: print("App is active")
|
||||
case .inactive: print("App is inactive")
|
||||
case .background: print("App in background - tracking continues")
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For advanced lifecycle patterns, see [references/ios_specific_features.md](references/ios_specific_features.md#app-lifecycle-management)
|
||||
|
||||
### 5. Persistent Storage
|
||||
|
||||
Store configuration using AppStorage:
|
||||
|
||||
```swift
|
||||
struct SleepTrackingView: View {
|
||||
@AppStorage("sleepapp+apikey") private var apiKey = ""
|
||||
@AppStorage("sleepapp+userid") private var userId = ""
|
||||
// Values automatically persist across app launches
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Patterns
|
||||
|
||||
```swift
|
||||
func handleError(_ error: Asleep.AsleepError) {
|
||||
switch error {
|
||||
case .micPermission:
|
||||
// Guide user to Settings
|
||||
showMicPermissionAlert()
|
||||
|
||||
case .audioSessionError:
|
||||
// Another app is using microphone
|
||||
showAudioUnavailableAlert()
|
||||
|
||||
case let .httpStatus(code, _, message):
|
||||
switch code {
|
||||
case 403: // Session already active on another device
|
||||
case 404: // Session not found
|
||||
default: break
|
||||
}
|
||||
|
||||
default:
|
||||
showGenericError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Retry with Exponential Backoff
|
||||
|
||||
```swift
|
||||
func startTrackingWithRetry() {
|
||||
trackingManager?.startTracking()
|
||||
}
|
||||
|
||||
func didFail(error: Asleep.AsleepError) {
|
||||
if isTransientError(error) && retryCount < maxRetries {
|
||||
retryCount += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pow(2.0, Double(retryCount))) {
|
||||
self.startTrackingWithRetry()
|
||||
}
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For comprehensive error handling patterns, see [references/advanced_patterns.md](references/advanced_patterns.md#error-recovery-patterns)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. State Management
|
||||
|
||||
Use `@Published` properties for reactive UI updates:
|
||||
|
||||
```swift
|
||||
final class SleepTrackingViewModel: ObservableObject {
|
||||
@Published var isTracking = false
|
||||
@Published var error: String?
|
||||
// UI automatically updates when values change
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Main Thread Safety
|
||||
|
||||
Always update UI on main thread:
|
||||
|
||||
```swift
|
||||
func didCreate() {
|
||||
Task { @MainActor in // Ensures main thread
|
||||
self.isTracking = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Resource Cleanup
|
||||
|
||||
```swift
|
||||
final class SleepTrackingViewModel: ObservableObject {
|
||||
deinit {
|
||||
trackingManager = nil
|
||||
reports = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. User Experience
|
||||
|
||||
Provide clear visual feedback with loading states, progress indicators, and error messages. Disable controls appropriately during tracking.
|
||||
|
||||
### 5. Testing Considerations
|
||||
|
||||
Use dependency injection for testable code:
|
||||
|
||||
```swift
|
||||
protocol SleepTrackingManagerProtocol {
|
||||
func startTracking()
|
||||
func stopTracking()
|
||||
}
|
||||
|
||||
// Production and mock implementations
|
||||
```
|
||||
|
||||
For complete testing patterns, see [references/advanced_patterns.md](references/advanced_patterns.md#testing-patterns)
|
||||
|
||||
## Common Integration Patterns
|
||||
|
||||
### Pattern 1: Simple Single-View App
|
||||
|
||||
Best for basic sleep tracking with minimal features. Single view with tracking controls.
|
||||
|
||||
### Pattern 2: Multi-View App with Navigation
|
||||
|
||||
Best for apps with reports, settings, and history. Uses TabView for navigation between Track, History, and Settings.
|
||||
|
||||
### Pattern 3: Centralized SDK Manager
|
||||
|
||||
Best for complex apps sharing SDK instance across views. Single source of truth with `AsleepSDKManager.shared`.
|
||||
|
||||
For complete implementation of all patterns, see [references/advanced_patterns.md](references/advanced_patterns.md)
|
||||
|
||||
## Real-time Data Access
|
||||
|
||||
Access preliminary sleep data during tracking (available after sequence 10):
|
||||
|
||||
```swift
|
||||
func analysing(session: Asleep.Model.Session) {
|
||||
Task { @MainActor in
|
||||
if let sleepStages = session.sleepStages {
|
||||
updateRealtimeChart(stages: sleepStages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didUpload(sequence: Int) {
|
||||
// Real-time data available every 10 sequences after sequence 10
|
||||
if sequence >= 10 && sequence % 10 == 0 {
|
||||
// SDK automatically calls analysing() delegate
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fetching Reports
|
||||
|
||||
Retrieve sleep session data after tracking:
|
||||
|
||||
```swift
|
||||
func fetchReport(sessionId: String) async {
|
||||
do {
|
||||
let report = try await reports?.report(sessionId: sessionId)
|
||||
// Process report data
|
||||
} catch {
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch multiple sessions
|
||||
func fetchReportList() async {
|
||||
let reportList = try await reports?.reports(
|
||||
fromDate: "2024-01-01",
|
||||
toDate: "2024-01-31"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tracking Doesn't Start
|
||||
|
||||
**Causes**: Missing microphone permission, empty API key/user ID, another app using microphone
|
||||
|
||||
**Solution**: Validate configuration and check microphone permission before starting
|
||||
|
||||
### Background Tracking Stops
|
||||
|
||||
**Causes**: Background audio mode not configured, memory pressure, force-closed app
|
||||
|
||||
**Solution**: Ensure `UIBackgroundModes` includes `audio` in Info.plist
|
||||
|
||||
### Reports Not Available
|
||||
|
||||
**Causes**: Session processing incomplete (takes time), minimum duration not met (5 minutes), network issues
|
||||
|
||||
**Solution**: Implement retry logic with exponential backoff when fetching reports
|
||||
|
||||
For detailed troubleshooting, see the complete implementation examples in references/
|
||||
|
||||
## Sample Code Reference
|
||||
|
||||
This skill is based on the official Asleep iOS sample app:
|
||||
|
||||
- **MainViewModel.swift**: Complete ViewModel with all delegates
|
||||
- **MainView.swift**: SwiftUI view with tracking controls
|
||||
- **StartSleepIntent.swift / StopSleepIntent.swift**: Siri Shortcuts
|
||||
- **ReportView.swift**: Sleep report display
|
||||
- **Info.plist**: Required iOS permissions
|
||||
|
||||
Sample app: [Asleep iOS Sample App](https://github.com/asleep-ai/asleep-sdk-ios-sampleapp-public)
|
||||
|
||||
## Resources
|
||||
|
||||
### Official Documentation
|
||||
|
||||
- **iOS Get Started**: https://docs-en.asleep.ai/docs/ios-get-started.md
|
||||
- **iOS Error Codes**: https://docs-en.asleep.ai/docs/ios-error-codes.md
|
||||
- **AsleepConfig Reference**: https://docs-en.asleep.ai/docs/ios-asleep-config.md
|
||||
- **SleepTrackingManager Reference**: https://docs-en.asleep.ai/docs/ios-sleep-tracking-manager.md
|
||||
- **Sample App Guide**: https://docs-en.asleep.ai/docs/sample-app.md
|
||||
|
||||
### Apple Documentation
|
||||
|
||||
- **SwiftUI**: https://developer.apple.com/documentation/swiftui
|
||||
- **Combine**: https://developer.apple.com/documentation/combine
|
||||
- **AVAudioSession**: https://developer.apple.com/documentation/avfaudio/avaudiosession
|
||||
- **App Intents**: https://developer.apple.com/documentation/appintents
|
||||
- **Background Modes**: https://developer.apple.com/documentation/xcode/configuring-background-execution-modes
|
||||
|
||||
### Related Skills
|
||||
|
||||
- **sleeptrack-foundation**: Core Asleep concepts, authentication, and data structures
|
||||
- **sleeptrack-android**: Android-specific implementation guide
|
||||
- **sleeptrack-be**: Backend API integration
|
||||
|
||||
## Next Steps
|
||||
|
||||
After integrating the iOS SDK:
|
||||
|
||||
1. Test thoroughly across different iOS devices and versions
|
||||
2. Implement proper error handling for all edge cases
|
||||
3. Add user-friendly error messages and recovery flows
|
||||
4. Consider HealthKit integration for data export
|
||||
5. Implement notification reminders for tracking
|
||||
6. Add data visualization for sleep trends
|
||||
7. Consider Apple Watch companion app
|
||||
8. Submit to App Store with proper privacy declarations
|
||||
460
skills/sleeptrack-ios/references/advanced_patterns.md
Normal file
460
skills/sleeptrack-ios/references/advanced_patterns.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# Advanced Integration Patterns
|
||||
|
||||
Comprehensive patterns for different app architectures and advanced error handling strategies.
|
||||
|
||||
## Pattern 1: Simple Single-View App
|
||||
|
||||
Best for: Basic sleep tracking app with minimal features.
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct SimpleSleepApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
SleepTrackingView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advantages
|
||||
|
||||
- Simple architecture
|
||||
- Easy to understand and maintain
|
||||
- Minimal code overhead
|
||||
- Fast development
|
||||
|
||||
### Use Cases
|
||||
|
||||
- MVP or prototype apps
|
||||
- Single-purpose sleep trackers
|
||||
- Learning/demo applications
|
||||
|
||||
## Pattern 2: Multi-View App with Navigation
|
||||
|
||||
Best for: Apps with reports, settings, and history.
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
SleepTrackingView()
|
||||
.tabItem {
|
||||
Label("Track", systemImage: "moon.zzz")
|
||||
}
|
||||
|
||||
ReportHistoryView()
|
||||
.tabItem {
|
||||
Label("History", systemImage: "chart.bar")
|
||||
}
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Supporting Views
|
||||
|
||||
```swift
|
||||
struct ReportHistoryView: View {
|
||||
@StateObject private var viewModel = ReportHistoryViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List(viewModel.sessions) { session in
|
||||
NavigationLink {
|
||||
ReportDetailView(sessionId: session.id)
|
||||
} label: {
|
||||
SessionRow(session: session)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sleep History")
|
||||
.onAppear {
|
||||
viewModel.loadSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@AppStorage("sleepapp+apikey") private var apiKey = ""
|
||||
@AppStorage("sleepapp+userid") private var userId = ""
|
||||
@AppStorage("sleepapp+notifications") private var notificationsEnabled = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("Account") {
|
||||
TextField("User ID", text: $userId)
|
||||
SecureField("API Key", text: $apiKey)
|
||||
}
|
||||
|
||||
Section("Preferences") {
|
||||
Toggle("Enable Notifications", isOn: $notificationsEnabled)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advantages
|
||||
|
||||
- Clear separation of concerns
|
||||
- Scalable for additional features
|
||||
- Familiar tab-based navigation
|
||||
- Easy to add new sections
|
||||
|
||||
## Pattern 3: Centralized SDK Manager
|
||||
|
||||
Best for: Complex apps sharing SDK instance across views.
|
||||
|
||||
```swift
|
||||
final class AsleepSDKManager: ObservableObject {
|
||||
static let shared = AsleepSDKManager()
|
||||
|
||||
@Published var config: Asleep.Config?
|
||||
@Published var isInitialized = false
|
||||
@Published var error: String?
|
||||
|
||||
private var trackingManager: Asleep.SleepTrackingManager?
|
||||
private var reports: Asleep.Reports?
|
||||
|
||||
private init() {}
|
||||
|
||||
func initialize(apiKey: String, userId: String) {
|
||||
Asleep.initAsleepConfig(
|
||||
apiKey: apiKey,
|
||||
userId: userId,
|
||||
delegate: self
|
||||
)
|
||||
}
|
||||
|
||||
func getTrackingManager() -> Asleep.SleepTrackingManager? {
|
||||
guard let config else { return nil }
|
||||
if trackingManager == nil {
|
||||
trackingManager = Asleep.createSleepTrackingManager(
|
||||
config: config,
|
||||
delegate: self
|
||||
)
|
||||
}
|
||||
return trackingManager
|
||||
}
|
||||
|
||||
func getReports() -> Asleep.Reports? {
|
||||
guard let config else { return nil }
|
||||
if reports == nil {
|
||||
reports = Asleep.createReports(config: config)
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
func reset() {
|
||||
trackingManager = nil
|
||||
reports = nil
|
||||
config = nil
|
||||
isInitialized = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegates
|
||||
extension AsleepSDKManager: AsleepConfigDelegate {
|
||||
func userDidJoin(userId: String, config: Asleep.Config) {
|
||||
Task { @MainActor in
|
||||
self.config = config
|
||||
self.isInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
func didFailUserJoin(error: Asleep.AsleepError) {
|
||||
Task { @MainActor in
|
||||
self.error = "Failed to join: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func userDidDelete(userId: String) {
|
||||
Task { @MainActor in
|
||||
reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in views
|
||||
struct SleepTrackingView: View {
|
||||
@ObservedObject var sdkManager = AsleepSDKManager.shared
|
||||
@StateObject private var trackingState = TrackingStateViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if sdkManager.isInitialized {
|
||||
TrackingControls(manager: sdkManager.getTrackingManager())
|
||||
} else {
|
||||
ConfigurationView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReportHistoryView: View {
|
||||
@ObservedObject var sdkManager = AsleepSDKManager.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if sdkManager.isInitialized {
|
||||
ReportList(reports: sdkManager.getReports())
|
||||
} else {
|
||||
Text("Please configure the app first")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advantages
|
||||
|
||||
- Single source of truth for SDK state
|
||||
- Prevents duplicate SDK instances
|
||||
- Centralized error handling
|
||||
- Easy to manage lifecycle across app
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
```swift
|
||||
protocol AsleepSDKManagerProtocol {
|
||||
var config: Asleep.Config? { get }
|
||||
var isInitialized: Bool { get }
|
||||
func initialize(apiKey: String, userId: String)
|
||||
func getTrackingManager() -> Asleep.SleepTrackingManager?
|
||||
}
|
||||
|
||||
// For testing
|
||||
class MockSDKManager: AsleepSDKManagerProtocol, ObservableObject {
|
||||
@Published var config: Asleep.Config?
|
||||
@Published var isInitialized = false
|
||||
|
||||
func initialize(apiKey: String, userId: String) {
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
func getTrackingManager() -> Asleep.SleepTrackingManager? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Recovery Patterns
|
||||
|
||||
### Automatic Retry with Exponential Backoff
|
||||
|
||||
```swift
|
||||
final class SleepTrackingViewModel: ObservableObject {
|
||||
private var retryCount = 0
|
||||
private let maxRetries = 3
|
||||
|
||||
func startTrackingWithRetry() {
|
||||
trackingManager?.startTracking()
|
||||
}
|
||||
|
||||
func didFail(error: Asleep.AsleepError) {
|
||||
if isTransientError(error) && retryCount < maxRetries {
|
||||
retryCount += 1
|
||||
let delay = pow(2.0, Double(retryCount))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.startTrackingWithRetry()
|
||||
}
|
||||
} else {
|
||||
retryCount = 0
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func isTransientError(_ error: Asleep.AsleepError) -> Bool {
|
||||
switch error {
|
||||
case .networkError, .uploadFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Categorization and Handling
|
||||
|
||||
```swift
|
||||
extension SleepTrackingViewModel {
|
||||
func handleError(_ error: Asleep.AsleepError) {
|
||||
switch error {
|
||||
case .micPermission:
|
||||
showAlert(
|
||||
title: "Microphone Access Required",
|
||||
message: "Please enable microphone access in Settings to track sleep.",
|
||||
action: openSettings
|
||||
)
|
||||
|
||||
case .audioSessionError:
|
||||
showAlert(
|
||||
title: "Audio Unavailable",
|
||||
message: "Another app is using the microphone. Please close it and try again."
|
||||
)
|
||||
|
||||
case let .httpStatus(code, _, message):
|
||||
switch code {
|
||||
case 403:
|
||||
showAlert(
|
||||
title: "Session Already Active",
|
||||
message: "Another device is tracking with this user ID."
|
||||
)
|
||||
case 404:
|
||||
showAlert(
|
||||
title: "Session Not Found",
|
||||
message: "The tracking session could not be found."
|
||||
)
|
||||
default:
|
||||
showAlert(
|
||||
title: "Error \(code)",
|
||||
message: message ?? "An unknown error occurred"
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
showAlert(
|
||||
title: "Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Report Fetching with Retry
|
||||
|
||||
```swift
|
||||
func fetchReportWithRetry(sessionId: String, maxAttempts: Int = 5) async {
|
||||
for attempt in 1...maxAttempts {
|
||||
do {
|
||||
let report = try await reports?.report(sessionId: sessionId)
|
||||
await MainActor.run {
|
||||
self.currentReport = report
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
if attempt < maxAttempts {
|
||||
// Wait before retrying (exponential backoff)
|
||||
let delay = UInt64(pow(2.0, Double(attempt))) * 1_000_000_000
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
} else {
|
||||
await MainActor.run {
|
||||
self.error = "Report not ready. Please try again later."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
```swift
|
||||
struct SleepTrackingView: View {
|
||||
@StateObject private var viewModel = SleepTrackingViewModel()
|
||||
@State private var offlineMode = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if offlineMode {
|
||||
OfflineModeView()
|
||||
} else {
|
||||
OnlineModeView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.onReceive(viewModel.$error) { error in
|
||||
if let error = error, isNetworkError(error) {
|
||||
offlineMode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isNetworkError(_ error: String) -> Bool {
|
||||
error.contains("network") || error.contains("connection")
|
||||
}
|
||||
}
|
||||
|
||||
struct OfflineModeView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Offline Mode")
|
||||
.font(.headline)
|
||||
|
||||
Text("Sleep tracking data will sync when connection is restored")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Dependency Injection for Testing
|
||||
|
||||
```swift
|
||||
protocol SleepTrackingManagerProtocol {
|
||||
func startTracking()
|
||||
func stopTracking()
|
||||
}
|
||||
|
||||
final class SleepTrackingViewModel: ObservableObject {
|
||||
private let trackingManager: SleepTrackingManagerProtocol
|
||||
|
||||
init(trackingManager: SleepTrackingManagerProtocol) {
|
||||
self.trackingManager = trackingManager
|
||||
}
|
||||
|
||||
func startTracking() {
|
||||
trackingManager.startTracking()
|
||||
}
|
||||
}
|
||||
|
||||
// For testing
|
||||
class MockTrackingManager: SleepTrackingManagerProtocol {
|
||||
var startTrackingCalled = false
|
||||
var stopTrackingCalled = false
|
||||
|
||||
func startTracking() {
|
||||
startTrackingCalled = true
|
||||
}
|
||||
|
||||
func stopTracking() {
|
||||
stopTrackingCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in tests
|
||||
func testStartTracking() {
|
||||
let mockManager = MockTrackingManager()
|
||||
let viewModel = SleepTrackingViewModel(trackingManager: mockManager)
|
||||
|
||||
viewModel.startTracking()
|
||||
|
||||
XCTAssertTrue(mockManager.startTrackingCalled)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,421 @@
|
||||
# Complete ViewModel Implementation
|
||||
|
||||
This reference provides full implementation examples for iOS sleep tracking using Combine and SwiftUI.
|
||||
|
||||
## Full ViewModel with Combine
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import Combine
|
||||
import AsleepSDK
|
||||
|
||||
final class SleepTrackingViewModel: ObservableObject {
|
||||
// MARK: - SDK Components
|
||||
private(set) var trackingManager: Asleep.SleepTrackingManager?
|
||||
private(set) var reports: Asleep.Reports?
|
||||
|
||||
// MARK: - Published State
|
||||
@Published var userId: String?
|
||||
@Published var sessionId: String?
|
||||
@Published var sequenceNumber: Int?
|
||||
@Published var error: String?
|
||||
@Published var isTracking = false
|
||||
@Published var currentReport: Asleep.Model.Report?
|
||||
@Published var reportList: [Asleep.Model.SleepSession]?
|
||||
@Published private(set) var config: Asleep.Config?
|
||||
|
||||
// MARK: - Initialization
|
||||
func initAsleepConfig(
|
||||
apiKey: String,
|
||||
userId: String,
|
||||
baseUrl: URL? = nil,
|
||||
callbackUrl: URL? = nil
|
||||
) {
|
||||
Asleep.initAsleepConfig(
|
||||
apiKey: apiKey,
|
||||
userId: userId.isEmpty ? nil : userId,
|
||||
baseUrl: baseUrl,
|
||||
callbackUrl: callbackUrl,
|
||||
delegate: self
|
||||
)
|
||||
|
||||
// Optional: Enable debug logging
|
||||
Asleep.setDebugLoggerDelegate(self)
|
||||
}
|
||||
|
||||
func initSleepTrackingManager() {
|
||||
guard let config else { return }
|
||||
trackingManager = Asleep.createSleepTrackingManager(
|
||||
config: config,
|
||||
delegate: self
|
||||
)
|
||||
}
|
||||
|
||||
func initReports() {
|
||||
guard let config else { return }
|
||||
reports = Asleep.createReports(config: config)
|
||||
}
|
||||
|
||||
// MARK: - Tracking Control
|
||||
func startTracking() {
|
||||
trackingManager?.startTracking()
|
||||
}
|
||||
|
||||
func stopTracking() {
|
||||
trackingManager?.stopTracking()
|
||||
initReports()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AsleepConfigDelegate
|
||||
extension SleepTrackingViewModel: AsleepConfigDelegate {
|
||||
func userDidJoin(userId: String, config: Asleep.Config) {
|
||||
Task { @MainActor in
|
||||
self.config = config
|
||||
self.userId = userId
|
||||
initSleepTrackingManager()
|
||||
}
|
||||
}
|
||||
|
||||
func didFailUserJoin(error: Asleep.AsleepError) {
|
||||
Task { @MainActor in
|
||||
self.error = "Failed to join: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func userDidDelete(userId: String) {
|
||||
Task { @MainActor in
|
||||
self.userId = nil
|
||||
self.config = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AsleepSleepTrackingManagerDelegate
|
||||
extension SleepTrackingViewModel: AsleepSleepTrackingManagerDelegate {
|
||||
func didCreate() {
|
||||
Task { @MainActor in
|
||||
self.isTracking = true
|
||||
self.error = nil
|
||||
}
|
||||
}
|
||||
|
||||
func didUpload(sequence: Int) {
|
||||
Task { @MainActor in
|
||||
self.sequenceNumber = sequence
|
||||
}
|
||||
}
|
||||
|
||||
func didClose(sessionId: String) {
|
||||
Task { @MainActor in
|
||||
self.isTracking = false
|
||||
self.sessionId = sessionId
|
||||
}
|
||||
}
|
||||
|
||||
func didFail(error: Asleep.AsleepError) {
|
||||
switch error {
|
||||
case let .httpStatus(code, _, message) where code == 403 || code == 404:
|
||||
Task { @MainActor in
|
||||
self.isTracking = false
|
||||
self.error = "\(code): \(message ?? "Unknown error")"
|
||||
}
|
||||
default:
|
||||
Task { @MainActor in
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didInterrupt() {
|
||||
Task { @MainActor in
|
||||
self.error = "Tracking interrupted (e.g., phone call)"
|
||||
}
|
||||
}
|
||||
|
||||
func didResume() {
|
||||
Task { @MainActor in
|
||||
self.error = nil
|
||||
}
|
||||
}
|
||||
|
||||
func micPermissionWasDenied() {
|
||||
Task { @MainActor in
|
||||
self.isTracking = false
|
||||
self.error = "Microphone permission denied. Please enable in Settings."
|
||||
}
|
||||
}
|
||||
|
||||
func analysing(session: Asleep.Model.Session) {
|
||||
// Optional: Handle real-time analysis data
|
||||
print("Real-time analysis:", session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AsleepDebugLoggerDelegate (Optional)
|
||||
extension SleepTrackingViewModel: AsleepDebugLoggerDelegate {
|
||||
func didPrint(message: String) {
|
||||
print("[Asleep SDK]", message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete SwiftUI View
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct SleepTrackingView: View {
|
||||
@StateObject private var viewModel = SleepTrackingViewModel()
|
||||
@AppStorage("sampleapp+apikey") private var apiKey = ""
|
||||
@AppStorage("sampleapp+userid") private var userId = ""
|
||||
|
||||
@State private var startTime: Date?
|
||||
@State private var showingReport = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Configuration Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Configuration")
|
||||
.font(.headline)
|
||||
|
||||
TextField("API Key", text: $apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(viewModel.isTracking)
|
||||
|
||||
TextField("User ID", text: $userId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(viewModel.isTracking)
|
||||
}
|
||||
.padding()
|
||||
|
||||
// Status Section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Status")
|
||||
.font(.headline)
|
||||
|
||||
if let error = viewModel.error {
|
||||
Text("Error: \(error)")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
if viewModel.isTracking {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Tracking...")
|
||||
}
|
||||
|
||||
if let sequence = viewModel.sequenceNumber {
|
||||
Text("Sequence: \(sequence)")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
if let sessionId = viewModel.sessionId {
|
||||
Text("Session ID: \(sessionId)")
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
// Tracking Control
|
||||
Button(action: {
|
||||
if viewModel.isTracking {
|
||||
stopTracking()
|
||||
} else {
|
||||
startTracking()
|
||||
}
|
||||
}) {
|
||||
Text(viewModel.isTracking ? "Stop Tracking" : "Start Tracking")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(viewModel.isTracking ? Color.red : Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(apiKey.isEmpty || userId.isEmpty)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Report Access
|
||||
if viewModel.sessionId != nil {
|
||||
Button("View Report") {
|
||||
fetchReport()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.sheet(isPresented: $showingReport) {
|
||||
ReportView(report: viewModel.currentReport)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTracking() {
|
||||
viewModel.sessionId = nil
|
||||
viewModel.sequenceNumber = nil
|
||||
|
||||
if viewModel.config == nil {
|
||||
viewModel.initAsleepConfig(
|
||||
apiKey: apiKey,
|
||||
userId: userId
|
||||
)
|
||||
} else {
|
||||
viewModel.startTracking()
|
||||
}
|
||||
|
||||
startTime = Date()
|
||||
}
|
||||
|
||||
private func stopTracking() {
|
||||
viewModel.stopTracking()
|
||||
}
|
||||
|
||||
private func fetchReport() {
|
||||
guard let sessionId = viewModel.sessionId else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
let report = try await viewModel.reports?.report(sessionId: sessionId)
|
||||
await MainActor.run {
|
||||
viewModel.currentReport = report
|
||||
showingReport = true
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
viewModel.error = "Failed to fetch report: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Report View
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import AsleepSDK
|
||||
|
||||
struct ReportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let report: Asleep.Model.Report?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
if let report = report {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Session Information
|
||||
Section("Session Information") {
|
||||
InfoRow(label: "Session ID", value: report.session.id)
|
||||
InfoRow(label: "Start Time", value: report.session.startTime.formatted())
|
||||
if let endTime = report.session.endTime {
|
||||
InfoRow(label: "End Time", value: endTime.formatted())
|
||||
}
|
||||
InfoRow(label: "State", value: report.session.state.rawValue)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Sleep Statistics
|
||||
if let stat = report.stat {
|
||||
Section("Sleep Statistics") {
|
||||
StatRow(label: "Sleep Efficiency", value: stat.sleepEfficiency, unit: "%")
|
||||
StatRow(label: "Sleep Latency", value: stat.sleepLatency, unit: "min")
|
||||
StatRow(label: "Total Sleep Time", value: stat.sleepTime, unit: "min")
|
||||
StatRow(label: "Time in Bed", value: stat.timeInBed, unit: "min")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Section("Sleep Stages") {
|
||||
StatRow(label: "Deep Sleep", value: stat.timeInDeep, unit: "min")
|
||||
StatRow(label: "Light Sleep", value: stat.timeInLight, unit: "min")
|
||||
StatRow(label: "REM Sleep", value: stat.timeInRem, unit: "min")
|
||||
StatRow(label: "Wake Time", value: stat.timeInWake, unit: "min")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Section("Snoring Analysis") {
|
||||
StatRow(label: "Time Snoring", value: stat.timeInSnoring, unit: "min")
|
||||
StatRow(label: "Snoring Count", value: stat.snoringCount, unit: "times")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
Text("No report available")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sleep Report")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InfoRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatRow: View {
|
||||
let label: String
|
||||
let value: Int?
|
||||
let unit: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
if let value = value {
|
||||
Text("\(value) \(unit)")
|
||||
} else {
|
||||
Text("N/A")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Section<Content: View>: View {
|
||||
let title: String
|
||||
let content: Content
|
||||
|
||||
init(_ title: String, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
284
skills/sleeptrack-ios/references/ios_specific_features.md
Normal file
284
skills/sleeptrack-ios/references/ios_specific_features.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# iOS-Specific Features
|
||||
|
||||
Detailed implementation guides for iOS platform features including Siri Shortcuts, background modes, permissions, lifecycle management, and persistent storage.
|
||||
|
||||
## Siri Shortcuts Integration
|
||||
|
||||
Enable voice-activated tracking with App Intents (iOS 16+):
|
||||
|
||||
```swift
|
||||
// StartSleepIntent.swift
|
||||
import AppIntents
|
||||
|
||||
@available(iOS 16, *)
|
||||
struct StartSleepIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Start Sleep"
|
||||
static var description = IntentDescription("Start Sleep Tracking")
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
NotificationCenter.default.post(name: .startSleep, object: nil)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// StopSleepIntent.swift
|
||||
@available(iOS 16, *)
|
||||
struct StopSleepIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Stop Sleep"
|
||||
static var description = IntentDescription("Stop Sleep Tracking")
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
NotificationCenter.default.post(name: .stopSleep, object: nil)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// Notification extensions
|
||||
extension Notification.Name {
|
||||
static let startSleep = Notification.Name("startSleep")
|
||||
static let stopSleep = Notification.Name("stopSleep")
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Shortcuts in Views
|
||||
|
||||
```swift
|
||||
struct SleepTrackingView: View {
|
||||
@StateObject private var viewModel = SleepTrackingViewModel()
|
||||
|
||||
var body: some View {
|
||||
// ... view content ...
|
||||
.onReceive(NotificationCenter.default.publisher(for: .startSleep)) { _ in
|
||||
if !viewModel.isTracking {
|
||||
startTracking()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .stopSleep)) { _ in
|
||||
if viewModel.isTracking {
|
||||
stopTracking()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Users can then say: "Hey Siri, start sleep" or "Hey Siri, stop sleep"
|
||||
|
||||
## Background Audio Mode
|
||||
|
||||
Configure background audio to maintain tracking during sleep.
|
||||
|
||||
### Info.plist Configuration
|
||||
|
||||
```xml
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- iOS automatically maintains background audio session during tracking
|
||||
- App remains active in background while microphone is in use
|
||||
- User sees audio indicator (red bar/pill) showing active recording
|
||||
- No additional code needed beyond Info.plist configuration
|
||||
- System handles audio session management automatically
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. Inform users why the app needs background audio mode
|
||||
2. Display clear status indicators when tracking is active
|
||||
3. Handle audio interruptions gracefully (phone calls, other apps)
|
||||
4. Test background behavior thoroughly on physical devices
|
||||
|
||||
## Microphone Permission Handling
|
||||
|
||||
Request and handle microphone permission properly:
|
||||
|
||||
```swift
|
||||
import AVFoundation
|
||||
|
||||
func requestMicrophonePermission() async -> Bool {
|
||||
switch AVAudioSession.sharedInstance().recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
return await AVAudioSession.sharedInstance().requestRecordPermission()
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in SwiftUI
|
||||
Button("Start Tracking") {
|
||||
Task {
|
||||
let hasPermission = await requestMicrophonePermission()
|
||||
if hasPermission {
|
||||
startTracking()
|
||||
} else {
|
||||
showPermissionAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Alert Handling
|
||||
|
||||
```swift
|
||||
struct PermissionAlert: ViewModifier {
|
||||
@Binding var showAlert: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert("Microphone Access Required", isPresented: $showAlert) {
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Please enable microphone access in Settings to track sleep.")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Permission Status
|
||||
|
||||
```swift
|
||||
func checkMicrophonePermission() -> Bool {
|
||||
let status = AVAudioSession.sharedInstance().recordPermission
|
||||
return status == .granted
|
||||
}
|
||||
```
|
||||
|
||||
## App Lifecycle Management
|
||||
|
||||
Handle app state transitions gracefully:
|
||||
|
||||
```swift
|
||||
struct SleepTrackingView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
// ... view content ...
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
switch newPhase {
|
||||
case .active:
|
||||
print("App is active")
|
||||
// Refresh UI state if needed
|
||||
case .inactive:
|
||||
print("App is inactive")
|
||||
// Prepare for potential backgrounding
|
||||
case .background:
|
||||
// Tracking continues in background with audio mode
|
||||
print("App is in background")
|
||||
// Minimal operations only
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Lifecycle Handling
|
||||
|
||||
```swift
|
||||
class AppLifecycleObserver: ObservableObject {
|
||||
@Published var isActive = true
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleEnterBackground()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleEnterForeground()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleEnterBackground() {
|
||||
isActive = false
|
||||
// Save state, reduce operations
|
||||
}
|
||||
|
||||
private func handleEnterForeground() {
|
||||
isActive = true
|
||||
// Refresh state, resume operations
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Persistent Storage with AppStorage
|
||||
|
||||
Store configuration persistently across app launches:
|
||||
|
||||
```swift
|
||||
struct SleepTrackingView: View {
|
||||
@AppStorage("sleepapp+apikey") private var apiKey = ""
|
||||
@AppStorage("sleepapp+userid") private var userId = ""
|
||||
@AppStorage("sleepapp+baseurl") private var baseUrl = ""
|
||||
|
||||
// Values automatically persist across app launches
|
||||
// Uses UserDefaults under the hood
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Storage Keys
|
||||
|
||||
```swift
|
||||
extension String {
|
||||
static let apiKeyStorage = "sleepapp+apikey"
|
||||
static let userIdStorage = "sleepapp+userid"
|
||||
static let baseUrlStorage = "sleepapp+baseurl"
|
||||
}
|
||||
|
||||
struct SleepTrackingView: View {
|
||||
@AppStorage(.apiKeyStorage) private var apiKey = ""
|
||||
@AppStorage(.userIdStorage) private var userId = ""
|
||||
@AppStorage(.baseUrlStorage) private var baseUrl = ""
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Persistent Storage
|
||||
|
||||
```swift
|
||||
class PersistentSettings: ObservableObject {
|
||||
@AppStorage("sleepapp+apikey") var apiKey = ""
|
||||
@AppStorage("sleepapp+userid") var userId = ""
|
||||
@AppStorage("sleepapp+baseurl") var baseUrl = ""
|
||||
@AppStorage("sleepapp+notifications") var notificationsEnabled = false
|
||||
@AppStorage("sleepapp+lastSessionId") var lastSessionId = ""
|
||||
|
||||
func clearAll() {
|
||||
apiKey = ""
|
||||
userId = ""
|
||||
baseUrl = ""
|
||||
notificationsEnabled = false
|
||||
lastSessionId = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
struct SettingsView: View {
|
||||
@StateObject private var settings = PersistentSettings()
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("API Key", text: $settings.apiKey)
|
||||
TextField("User ID", text: $settings.userId)
|
||||
Toggle("Notifications", isOn: $settings.notificationsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user