Initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user