feat(ui): complete ABox app experience redesign #10
@@ -1,5 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:name=".ABoxManagerApplication"
|
android:name=".ABoxManagerApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ dependencies {
|
|||||||
kapt(libs.room.compiler)
|
kapt(libs.room.compiler)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.test.runner)
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
|||||||
+7
@@ -7,6 +7,8 @@ import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
|
|||||||
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
||||||
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
||||||
import com.flagship.abox.manager.domain.ActivityRepository
|
import com.flagship.abox.manager.domain.ActivityRepository
|
||||||
|
import com.flagship.abox.manager.domain.NotificationGateway
|
||||||
|
import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -29,4 +31,9 @@ object AutomationModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway =
|
||||||
|
AndroidNotificationGateway(context)
|
||||||
}
|
}
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.flagship.abox.manager.automation.notification
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.NotificationGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
|
||||||
|
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
|
||||||
|
}
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
|
||||||
|
)
|
||||||
|
return try {
|
||||||
|
NotificationManagerCompat.from(context).notify(
|
||||||
|
profileId.value.hashCode(),
|
||||||
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(message)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object { const val CHANNEL_ID = "automation_events" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
interface NotificationGateway {
|
||||||
|
suspend fun show(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
}
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
package com.flagship.abox.manager.domain
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class SceneExecutionId(val value: String)
|
||||||
|
|
||||||
|
enum class SceneExecutionSource { USER, AUTOMATION }
|
||||||
|
|
||||||
|
sealed interface SceneExecutionUpdate {
|
||||||
|
data class Started(val executionId: SceneExecutionId, val scene: Scene) : SceneExecutionUpdate
|
||||||
|
data class StepPending(val action: SceneAction) : SceneExecutionUpdate
|
||||||
|
data class StepRunning(val action: SceneAction) : SceneExecutionUpdate
|
||||||
|
data class StepFinished(val result: SceneActionExecutionResult) : SceneExecutionUpdate
|
||||||
|
data class Completed(val result: SceneExecutionResult) : SceneExecutionUpdate
|
||||||
|
data class Rejected(val error: DomainError) : SceneExecutionUpdate
|
||||||
|
}
|
||||||
|
|
||||||
enum class SceneActionOutcome {
|
enum class SceneActionOutcome {
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
FAILURE,
|
FAILURE,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package com.flagship.abox.manager.domain
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent
|
* Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent
|
||||||
* actions; callers receive a [SceneExecutionResult] that summarizes every step.
|
* actions; callers receive a [SceneExecutionResult] that summarizes every step.
|
||||||
@@ -9,4 +13,23 @@ package com.flagship.abox.manager.domain
|
|||||||
*/
|
*/
|
||||||
interface SceneExecutor {
|
interface SceneExecutor {
|
||||||
suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult>
|
suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult>
|
||||||
|
|
||||||
|
fun execute(
|
||||||
|
scene: Scene,
|
||||||
|
executionId: SceneExecutionId = SceneExecutionId(UUID.randomUUID().toString()),
|
||||||
|
source: SceneExecutionSource = SceneExecutionSource.USER,
|
||||||
|
): Flow<SceneExecutionUpdate> = flow {
|
||||||
|
emit(SceneExecutionUpdate.Started(executionId, scene))
|
||||||
|
when (val result = execute(scene)) {
|
||||||
|
is DomainResult.Failure -> emit(SceneExecutionUpdate.Rejected(result.error))
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
result.value.results.forEach { actionResult ->
|
||||||
|
emit(SceneExecutionUpdate.StepPending(actionResult.action))
|
||||||
|
emit(SceneExecutionUpdate.StepRunning(actionResult.action))
|
||||||
|
emit(SceneExecutionUpdate.StepFinished(actionResult))
|
||||||
|
}
|
||||||
|
emit(SceneExecutionUpdate.Completed(result.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SceneExecutionFlowTest {
|
||||||
|
@Test
|
||||||
|
fun observableExecutionPublishesLifecycleEventsWithoutChangingLegacyResult() = runTest {
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("scene"),
|
||||||
|
profileId = ProviderProfileId("profile"),
|
||||||
|
name = "测试情景",
|
||||||
|
)
|
||||||
|
val executor = DefaultSceneExecutor(
|
||||||
|
socketGateway = object : SocketGateway {
|
||||||
|
override suspend fun getStatus(profileId: ProviderProfileId, deviceName: DeviceName) =
|
||||||
|
DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF))
|
||||||
|
override suspend fun setPower(profileId: ProviderProfileId, deviceName: DeviceName, powerState: SocketPowerState) =
|
||||||
|
DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
|
||||||
|
},
|
||||||
|
infraredGateway = object : InfraredGateway {
|
||||||
|
override suspend fun sendKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit)
|
||||||
|
override suspend fun learnKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit)
|
||||||
|
override suspend fun downloadCodes(profileId: ProviderProfileId, codes: List<InfraredCode>) = DomainResult.Success(emptyList<InfraredCodeDownloadResult>())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val updates = executor.execute(scene, SceneExecutionId("run-1")).toList()
|
||||||
|
|
||||||
|
assertTrue(updates.first() is SceneExecutionUpdate.Started)
|
||||||
|
assertTrue(updates.last() is SceneExecutionUpdate.Completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user