Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:58:23 +08:00
commit 9faa5d88f3
22 changed files with 9600 additions and 0 deletions

View File

@@ -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 -> ""
}
}
```

View File

@@ -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")
}
}
)
}
```

View 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
```

View 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"
}
```

View 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