feat(ui): complete ABox app experience redesign (#10)
## Summary - rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience - redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content - add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative - add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes - persist real activity events for device commands, infrared actions, and scene execution - stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option - update launcher icons and bilingual application name ## Verification - `:android:app:assembleDebug` - installed the debug APK on `emulator-5554` - manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions ## Notes - no automated test suite was run during the visual iteration cycle - `docs/superpowers/**` and local reference repositories are excluded from this PR Reviewed-on: #10 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.hilt.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.flagship.abox.manager.automation"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildFeatures { compose = true }
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":domain"))
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
kapt(libs.room.compiler)
|
||||
implementation(libs.hilt.android)
|
||||
kapt(libs.hilt.compiler)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.room.testing)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.flagship.abox.manager.automation
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.automation.data.ActivityEventEntity
|
||||
import com.flagship.abox.manager.automation.data.AutomationDatabase
|
||||
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
||||
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
||||
import com.flagship.abox.manager.domain.ActivityEvent
|
||||
import com.flagship.abox.manager.domain.ActivityEventCursor
|
||||
import com.flagship.abox.manager.domain.ActivityEventId
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import com.flagship.abox.manager.domain.ActivitySource
|
||||
import com.flagship.abox.manager.domain.ActivityTarget
|
||||
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SceneId
|
||||
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AutomationDatabaseTest {
|
||||
private lateinit var database: AutomationDatabase
|
||||
private lateinit var activityRepository: RoomActivityRepository
|
||||
private lateinit var runtimeRepository: RoomAutomationRuntimeRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AutomationDatabase::class.java).build()
|
||||
activityRepository = RoomActivityRepository(database.automationDao())
|
||||
runtimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = database.close()
|
||||
|
||||
@Test
|
||||
fun activityEventsAreIsolatedByProfileAndPageByCursor() = runTest {
|
||||
activityRepository.append(event("profile-a", "a", 10L))
|
||||
activityRepository.append(event("profile-a", "b", 10L))
|
||||
activityRepository.append(event("profile-b", "c", 10L))
|
||||
|
||||
val first = activityRepository.observeLatest(ProviderProfileId("profile-a"), 1).first()
|
||||
val firstEvent = (first as DomainResult.Success).value.single()
|
||||
val next = activityRepository.loadBefore(
|
||||
ProviderProfileId("profile-a"),
|
||||
ActivityEventCursor(firstEvent.occurredAtEpochMillis, firstEvent.id),
|
||||
1,
|
||||
)
|
||||
|
||||
assertEquals("b", firstEvent.id.value)
|
||||
assertEquals(listOf("a"), (next as DomainResult.Success).value.map { it.id.value })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun occurrenceClaimIsAtomic() = runTest {
|
||||
val first = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
|
||||
val second = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
|
||||
|
||||
assertTrue((first as DomainResult.Success).value)
|
||||
assertEquals(false, (second as DomainResult.Success).value)
|
||||
}
|
||||
|
||||
private fun event(profile: String, id: String, time: Long) = ActivityEvent(
|
||||
id = ActivityEventId(id),
|
||||
profileId = ProviderProfileId(profile),
|
||||
occurredAtEpochMillis = time,
|
||||
kind = ActivityEventKind.SOCKET_COMMAND,
|
||||
source = ActivitySource.USER,
|
||||
outcome = ActivityOutcome.SUCCEEDED,
|
||||
target = ActivityTarget(ActivityTargetType.DEVICE, id, "设备"),
|
||||
)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.flagship.abox.manager.automation.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AutomationDao {
|
||||
@Query("SELECT * FROM activity_events WHERE profileId = :profileId ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit")
|
||||
fun observeLatest(profileId: String, limit: Int): Flow<List<ActivityEventEntity>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM activity_events WHERE profileId = :profileId AND " +
|
||||
"(occurredAtEpochMillis < :beforeTime OR (occurredAtEpochMillis = :beforeTime AND id < :beforeId)) " +
|
||||
"ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit",
|
||||
)
|
||||
suspend fun loadBefore(profileId: String, beforeTime: Long, beforeId: String, limit: Int): List<ActivityEventEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertEvent(event: ActivityEventEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.flagship.abox.manager.automation.data
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false)
|
||||
abstract class AutomationDatabase : RoomDatabase() {
|
||||
abstract fun automationDao(): AutomationDao
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.flagship.abox.manager.automation.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
|
||||
@Entity(
|
||||
tableName = "activity_events",
|
||||
primaryKeys = ["profileId", "id"],
|
||||
indices = [Index(value = ["profileId", "occurredAtEpochMillis", "id"])],
|
||||
)
|
||||
data class ActivityEventEntity(
|
||||
val profileId: String,
|
||||
val id: String,
|
||||
val occurredAtEpochMillis: Long,
|
||||
val kind: String,
|
||||
val source: String,
|
||||
val outcome: String,
|
||||
val targetType: String,
|
||||
val targetStableId: String,
|
||||
val targetDisplayName: String,
|
||||
val errorCode: String?,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "automation_occurrences",
|
||||
primaryKeys = ["profileId", "sceneId", "triggerId", "occurrenceKey"],
|
||||
)
|
||||
data class AutomationOccurrenceEntity(
|
||||
val profileId: String,
|
||||
val sceneId: String,
|
||||
val triggerId: String,
|
||||
val occurrenceKey: String,
|
||||
)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.flagship.abox.manager.automation.data
|
||||
|
||||
import com.flagship.abox.manager.domain.ActivityEvent
|
||||
import com.flagship.abox.manager.domain.ActivityEventCursor
|
||||
import com.flagship.abox.manager.domain.ActivityEventId
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import com.flagship.abox.manager.domain.ActivityRepository
|
||||
import com.flagship.abox.manager.domain.ActivitySource
|
||||
import com.flagship.abox.manager.domain.ActivityTarget
|
||||
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class RoomActivityRepository(private val dao: AutomationDao) : ActivityRepository {
|
||||
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> =
|
||||
dao.observeLatest(profileId.value, limit.coerceAtLeast(0)).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) }
|
||||
|
||||
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int): DomainResult<List<ActivityEvent>> = try {
|
||||
DomainResult.Success(dao.loadBefore(profileId.value, before.occurredAtEpochMillis, before.id.value, limit.coerceAtLeast(0)).map { it.toDomain() })
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
|
||||
}
|
||||
|
||||
override suspend fun append(event: ActivityEvent): DomainResult<Unit> = try {
|
||||
dao.insertEvent(event.toEntity())
|
||||
DomainResult.Success(Unit)
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ActivityEventEntity.toDomain() = ActivityEvent(
|
||||
id = ActivityEventId(id),
|
||||
profileId = ProviderProfileId(profileId),
|
||||
occurredAtEpochMillis = occurredAtEpochMillis,
|
||||
kind = kind.toActivityEventKind(),
|
||||
source = source.toActivitySource(),
|
||||
outcome = outcome.toActivityOutcome(),
|
||||
target = ActivityTarget(targetType.toActivityTargetType(), targetStableId, targetDisplayName),
|
||||
errorCode = errorCode,
|
||||
)
|
||||
|
||||
private fun String.toActivityEventKind(): ActivityEventKind =
|
||||
runCatching { ActivityEventKind.valueOf(this) }.getOrDefault(ActivityEventKind.SCENE_ACTION)
|
||||
|
||||
private fun String.toActivitySource(): ActivitySource =
|
||||
runCatching { ActivitySource.valueOf(this) }.getOrDefault(ActivitySource.SYSTEM)
|
||||
|
||||
private fun String.toActivityOutcome(): ActivityOutcome =
|
||||
runCatching { ActivityOutcome.valueOf(this) }.getOrDefault(ActivityOutcome.FAILED)
|
||||
|
||||
private fun String.toActivityTargetType(): ActivityTargetType =
|
||||
runCatching { ActivityTargetType.valueOf(this) }.getOrDefault(ActivityTargetType.SCENE_ACTION)
|
||||
|
||||
private fun ActivityEvent.toEntity() = ActivityEventEntity(
|
||||
profileId = profileId.value,
|
||||
id = id.value,
|
||||
occurredAtEpochMillis = occurredAtEpochMillis,
|
||||
kind = kind.name,
|
||||
source = source.name,
|
||||
outcome = outcome.name,
|
||||
targetType = target.type.name,
|
||||
targetStableId = target.stableId,
|
||||
targetDisplayName = target.displayName,
|
||||
errorCode = errorCode,
|
||||
)
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.flagship.abox.manager.automation.data
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SceneId
|
||||
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||
|
||||
data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean)
|
||||
|
||||
interface AutomationRuntimeRepository {
|
||||
suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult<Boolean>
|
||||
}
|
||||
|
||||
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
|
||||
override suspend fun claimOccurrence(
|
||||
profileId: ProviderProfileId,
|
||||
sceneId: SceneId,
|
||||
triggerId: SceneTriggerId,
|
||||
occurrenceKey: String,
|
||||
): DomainResult<Boolean> = try {
|
||||
val inserted = dao.claimOccurrence(
|
||||
AutomationOccurrenceEntity(profileId.value, sceneId.value, triggerId.value, occurrenceKey),
|
||||
)
|
||||
DomainResult.Success(inserted != -1L)
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.flagship.abox.manager.automation.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.flagship.abox.manager.automation.data.AutomationDatabase
|
||||
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
|
||||
import com.flagship.abox.manager.automation.data.RoomActivityRepository
|
||||
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
|
||||
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||
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.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AutomationModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase =
|
||||
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideActivityRepository(database: AutomationDatabase): ActivityRepository = RoomActivityRepository(database.automationDao())
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideActivityEventWriter(repository: ActivityRepository): ActivityEventWriter =
|
||||
ActivityEventWriter(repository, CoroutineScope(SupervisorJob() + Dispatchers.IO))
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway =
|
||||
AndroidNotificationGateway(context)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
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 android.annotation.SuppressLint
|
||||
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 {
|
||||
@SuppressLint("MissingPermission")
|
||||
fun postNotification() {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
postNotification()
|
||||
DomainResult.Success(Unit)
|
||||
} catch (_: SecurityException) {
|
||||
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object { const val CHANNEL_ID = "automation_events" }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.flagship.abox.manager.automation.runtime
|
||||
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
|
||||
class AutomationBootstrapper(
|
||||
private val profileStore: ProviderProfileStore,
|
||||
private val scheduler: AutomationScheduler,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private var previousProfileId: ProviderProfileId? = null
|
||||
|
||||
fun start() {
|
||||
scope.launch {
|
||||
profileStore.observeActiveProfile().collectLatest { active ->
|
||||
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
|
||||
previousProfileId = active?.id
|
||||
scheduler.reconcileActiveProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.flagship.abox.manager.automation.runtime
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
||||
override suspend fun doWork(): Result =
|
||||
if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success()
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.flagship.abox.manager.automation.runtime
|
||||
|
||||
import com.flagship.abox.manager.domain.AutomationType
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import com.flagship.abox.manager.domain.Scene
|
||||
import com.flagship.abox.manager.domain.SceneRepository
|
||||
import com.flagship.abox.manager.domain.SessionGateway
|
||||
import com.flagship.abox.manager.domain.SessionValidity
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
interface AutomationScheduler {
|
||||
suspend fun reconcileActiveProfile(): DomainResult<Unit>
|
||||
suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit>
|
||||
suspend fun cancel(profileId: ProviderProfileId): DomainResult<Unit>
|
||||
}
|
||||
|
||||
interface AutomationScheduleStore {
|
||||
suspend fun replace(profileId: ProviderProfileId, scenes: List<Scene>): DomainResult<Unit>
|
||||
suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit>
|
||||
}
|
||||
|
||||
class ProfileAutomationScheduler(
|
||||
private val profileStore: ProviderProfileStore,
|
||||
private val sessionGateway: SessionGateway,
|
||||
private val sceneRepository: SceneRepository,
|
||||
private val scheduleStore: AutomationScheduleStore,
|
||||
) : AutomationScheduler {
|
||||
override suspend fun reconcileActiveProfile(): DomainResult<Unit> {
|
||||
val active = profileStore.activeProfile() ?: return DomainResult.Success(Unit)
|
||||
return reconcile(active.id)
|
||||
}
|
||||
|
||||
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
|
||||
val active = profileStore.activeProfile()
|
||||
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
|
||||
return when (val validity = sessionGateway.validateSession(profileId)) {
|
||||
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
|
||||
when (val result = sceneRepository.observeScenes(profileId).first()) {
|
||||
is DomainResult.Failure -> result
|
||||
is DomainResult.Success -> scheduleStore.replace(profileId, result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC })
|
||||
}
|
||||
} else {
|
||||
scheduleStore.remove(profileId)
|
||||
}
|
||||
is DomainResult.Failure -> scheduleStore.remove(profileId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancel(profileId: ProviderProfileId): DomainResult<Unit> = scheduleStore.remove(profileId)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.flagship.abox.manager.automation.runtime
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.Scene
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore {
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
override suspend fun replace(profileId: ProviderProfileId, scenes: List<Scene>): DomainResult<Unit> {
|
||||
if (scenes.isEmpty()) return remove(profileId)
|
||||
val request = PeriodicWorkRequestBuilder<AutomationPollWorker>(15, TimeUnit.MINUTES)
|
||||
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> {
|
||||
workManager.cancelUniqueWork(workName(profileId))
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}"
|
||||
|
||||
companion object { const val KEY_PROFILE_ID = "profile_id" }
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.flagship.abox.manager.automation.ui.activity
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
|
||||
@Composable
|
||||
fun ActivityRoute(profileId: ProviderProfileId, modifier: Modifier = Modifier, viewModel: ActivityViewModel = hiltViewModel()) {
|
||||
val state = viewModel.state.collectAsStateWithLifecycle().value
|
||||
LaunchedEffect(profileId) { viewModel.initialize(profileId) }
|
||||
ActivityScreen(state = state, onLoadEarlier = viewModel::loadEarlier, modifier = modifier)
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.flagship.abox.manager.automation.ui.activity
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flagship.abox.manager.domain.ActivityEvent
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivitySource
|
||||
import com.flagship.abox.manager.automation.R
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun ActivityScreen(
|
||||
state: ActivityUiState,
|
||||
onLoadEarlier: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
zoneId: ZoneId = ZoneId.systemDefault(),
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.activity_title), style = MaterialTheme.typography.headlineSmall)
|
||||
if (!state.loading && state.groups.isEmpty()) {
|
||||
Text(
|
||||
if (state.error) stringResource(R.string.activity_error) else stringResource(R.string.activity_empty),
|
||||
modifier = Modifier.testTag("activity_empty"),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("activity_timeline")) {
|
||||
state.groups.forEach { group ->
|
||||
item { Text(group.label, style = MaterialTheme.typography.titleMedium) }
|
||||
items(group.events, key = { it.id.value }) { event -> ActivityEventRow(event, zoneId) }
|
||||
}
|
||||
if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text(stringResource(R.string.activity_load_earlier)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) {
|
||||
val outcome = when (event.outcome) {
|
||||
ActivityOutcome.SUCCEEDED -> R.string.activity_outcome_success
|
||||
ActivityOutcome.FAILED -> R.string.activity_outcome_failed
|
||||
ActivityOutcome.CANCELLED -> R.string.activity_outcome_cancelled
|
||||
ActivityOutcome.AWAITING_CONFIRMATION -> R.string.activity_outcome_awaiting
|
||||
}
|
||||
val kind = when (event.kind) {
|
||||
ActivityEventKind.SOCKET_COMMAND -> R.string.activity_kind_socket_command
|
||||
ActivityEventKind.SOCKET_CONFIRMATION -> R.string.activity_kind_socket_confirmation
|
||||
ActivityEventKind.SENSOR_REFRESH -> R.string.activity_kind_sensor_refresh
|
||||
ActivityEventKind.INFRARED_SEND -> R.string.activity_kind_infrared_send
|
||||
ActivityEventKind.INFRARED_LEARN -> R.string.activity_kind_infrared_learn
|
||||
ActivityEventKind.SCENE_EXECUTION -> R.string.activity_kind_scene_execution
|
||||
ActivityEventKind.SCENE_ACTION -> R.string.activity_kind_scene_action
|
||||
else -> R.string.activity_title
|
||||
}
|
||||
val source = when (event.source) {
|
||||
ActivitySource.USER -> R.string.activity_source_user
|
||||
ActivitySource.AUTOMATION -> R.string.activity_source_automation
|
||||
ActivitySource.SYSTEM -> R.string.activity_source_system
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().testTag("activity_event_${event.id.value}"),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(stringResource(kind), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"${stringResource(outcome)} · ${stringResource(source)} · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
event.errorCode?.let { Text(stringResource(R.string.activity_error_code, it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.flagship.abox.manager.automation.ui.activity
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.domain.ActivityEvent
|
||||
import com.flagship.abox.manager.domain.ActivityEventCursor
|
||||
import com.flagship.abox.manager.domain.ActivityRepository
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ActivityGroup(val label: String, val events: List<ActivityEvent>)
|
||||
|
||||
data class ActivityUiState(
|
||||
val profileId: ProviderProfileId? = null,
|
||||
val events: List<ActivityEvent> = emptyList(),
|
||||
val groups: List<ActivityGroup> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val loadingEarlier: Boolean = false,
|
||||
val hasMore: Boolean = true,
|
||||
val error: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ActivityViewModel @Inject constructor(
|
||||
private val repository: ActivityRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(ActivityUiState())
|
||||
val state: StateFlow<ActivityUiState> = _state.asStateFlow()
|
||||
private var observation: Job? = null
|
||||
|
||||
fun initialize(profileId: ProviderProfileId, zoneId: ZoneId = ZoneId.systemDefault()) {
|
||||
if (_state.value.profileId == profileId && observation?.isActive == true) return
|
||||
observation?.cancel()
|
||||
_state.value = ActivityUiState(profileId = profileId)
|
||||
observation = viewModelScope.launch {
|
||||
repository.observeLatest(profileId, 100)
|
||||
.catch { _state.value = _state.value.copy(loading = false, error = true) }
|
||||
.collectLatest { result ->
|
||||
when (result) {
|
||||
is DomainResult.Success -> publish(result.value, zoneId)
|
||||
is DomainResult.Failure -> _state.value = _state.value.copy(loading = false, error = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadEarlier(zoneId: ZoneId = ZoneId.systemDefault()) {
|
||||
val current = _state.value
|
||||
val profileId = current.profileId ?: return
|
||||
val last = current.events.lastOrNull() ?: return
|
||||
if (current.loadingEarlier) return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loadingEarlier = true)
|
||||
val result = repository.loadBefore(profileId, ActivityEventCursor(last.occurredAtEpochMillis, last.id), 100)
|
||||
when (result) {
|
||||
is DomainResult.Success -> publish((current.events + result.value).distinctBy { it.id }, zoneId)
|
||||
is DomainResult.Failure -> _state.value = _state.value.copy(error = true)
|
||||
}
|
||||
_state.value = _state.value.copy(loadingEarlier = false, hasMore = resultHasMore(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(events: List<ActivityEvent>, zoneId: ZoneId) {
|
||||
val sorted = events.sortedWith(compareByDescending<ActivityEvent> { it.occurredAtEpochMillis }.thenByDescending { it.id.value })
|
||||
val today = Instant.now().atZone(zoneId).toLocalDate()
|
||||
val groups = sorted.groupBy {
|
||||
val date = Instant.ofEpochMilli(it.occurredAtEpochMillis).atZone(zoneId).toLocalDate()
|
||||
when (date) {
|
||||
today -> "今天"
|
||||
today.minusDays(1) -> "昨天"
|
||||
else -> "更早记录"
|
||||
}
|
||||
}.map { ActivityGroup(it.key, it.value) }
|
||||
_state.value = _state.value.copy(events = sorted, groups = groups, loading = false, error = false)
|
||||
}
|
||||
|
||||
private fun resultHasMore(result: DomainResult<List<ActivityEvent>>): Boolean =
|
||||
result is DomainResult.Success && result.value.size >= 100
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<resources>
|
||||
<string name="activity_title">活动记录</string>
|
||||
<string name="activity_empty">暂无活动记录</string>
|
||||
<string name="activity_error">活动记录加载失败</string>
|
||||
<string name="activity_load_earlier">加载更早记录</string>
|
||||
<string name="activity_source_user">手动操作</string>
|
||||
<string name="activity_source_automation">自动化</string>
|
||||
<string name="activity_source_system">系统</string>
|
||||
<string name="activity_outcome_success">成功</string>
|
||||
<string name="activity_outcome_failed">失败</string>
|
||||
<string name="activity_outcome_cancelled">已取消</string>
|
||||
<string name="activity_outcome_awaiting">等待确认</string>
|
||||
<string name="activity_kind_socket_command">插座控制</string>
|
||||
<string name="activity_kind_socket_confirmation">插座状态确认</string>
|
||||
<string name="activity_kind_sensor_refresh">传感器刷新</string>
|
||||
<string name="activity_kind_infrared_send">红外发送</string>
|
||||
<string name="activity_kind_infrared_learn">红外学习</string>
|
||||
<string name="activity_kind_scene_execution">场景执行</string>
|
||||
<string name="activity_kind_scene_action">场景动作</string>
|
||||
<string name="activity_error_code">错误码:%1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,21 @@
|
||||
<resources>
|
||||
<string name="activity_title">Activity</string>
|
||||
<string name="activity_empty">No activity yet</string>
|
||||
<string name="activity_error">Activity could not be loaded.</string>
|
||||
<string name="activity_load_earlier">Load earlier activity</string>
|
||||
<string name="activity_source_user">Manual</string>
|
||||
<string name="activity_source_automation">Automation</string>
|
||||
<string name="activity_source_system">System</string>
|
||||
<string name="activity_outcome_success">Succeeded</string>
|
||||
<string name="activity_outcome_failed">Failed</string>
|
||||
<string name="activity_outcome_cancelled">Cancelled</string>
|
||||
<string name="activity_outcome_awaiting">Awaiting confirmation</string>
|
||||
<string name="activity_kind_socket_command">Socket command</string>
|
||||
<string name="activity_kind_socket_confirmation">Socket confirmation</string>
|
||||
<string name="activity_kind_sensor_refresh">Sensor refresh</string>
|
||||
<string name="activity_kind_infrared_send">Infrared signal</string>
|
||||
<string name="activity_kind_infrared_learn">Infrared learning</string>
|
||||
<string name="activity_kind_scene_execution">Scene execution</string>
|
||||
<string name="activity_kind_scene_action">Scene action</string>
|
||||
<string name="activity_error_code">Error code: %1$s</string>
|
||||
</resources>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.flagship.abox.manager.automation.ui.activity
|
||||
|
||||
import com.flagship.abox.manager.domain.ActivityEvent
|
||||
import com.flagship.abox.manager.domain.ActivityEventCursor
|
||||
import com.flagship.abox.manager.domain.ActivityEventId
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import com.flagship.abox.manager.domain.ActivityRepository
|
||||
import com.flagship.abox.manager.domain.ActivitySource
|
||||
import com.flagship.abox.manager.domain.ActivityTarget
|
||||
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ActivityViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val scope = TestScope(dispatcher)
|
||||
private val profile = ProviderProfileId("profile")
|
||||
private val zone = ZoneId.of("Asia/Singapore")
|
||||
private lateinit var repository: TestActivityRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
repository = TestActivityRepository()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
@Test
|
||||
fun realEmptyRepositoryShowsEmptyState() = scope.runTest {
|
||||
val viewModel = ActivityViewModel(repository)
|
||||
viewModel.initialize(profile, zone)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.state.value.events.isEmpty())
|
||||
assertTrue(viewModel.state.value.groups.isEmpty())
|
||||
assertEquals(false, viewModel.state.value.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventsGroupByLocalDate() = scope.runTest {
|
||||
val now = Instant.now().atZone(zone).truncatedTo(ChronoUnit.MINUTES)
|
||||
repository.latest.value = DomainResult.Success(
|
||||
listOf(
|
||||
event("today", now.toInstant().toEpochMilli()),
|
||||
event("yesterday", now.minusDays(1).toInstant().toEpochMilli()),
|
||||
event("earlier", now.minusDays(3).toInstant().toEpochMilli()),
|
||||
),
|
||||
)
|
||||
val viewModel = ActivityViewModel(repository)
|
||||
viewModel.initialize(profile, zone)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("今天", "昨天", "更早记录"), viewModel.state.value.groups.map { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEarlierRemovesDuplicateEventsByStableId() = scope.runTest {
|
||||
val newest = event("newest", 30L)
|
||||
val older = event("older", 20L)
|
||||
repository.latest.value = DomainResult.Success(listOf(newest))
|
||||
repository.before = DomainResult.Success(listOf(newest, older))
|
||||
val viewModel = ActivityViewModel(repository)
|
||||
viewModel.initialize(profile, zone)
|
||||
advanceUntilIdle()
|
||||
viewModel.loadEarlier(zone)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("newest", "older"), viewModel.state.value.events.map { it.id.value })
|
||||
}
|
||||
|
||||
private fun event(id: String, time: Long) = ActivityEvent(
|
||||
id = ActivityEventId(id),
|
||||
profileId = profile,
|
||||
occurredAtEpochMillis = time,
|
||||
kind = ActivityEventKind.SOCKET_COMMAND,
|
||||
source = ActivitySource.USER,
|
||||
outcome = ActivityOutcome.SUCCEEDED,
|
||||
target = ActivityTarget(ActivityTargetType.DEVICE, id, id),
|
||||
)
|
||||
|
||||
private class TestActivityRepository : ActivityRepository {
|
||||
val latest = MutableStateFlow<DomainResult<List<ActivityEvent>>>(DomainResult.Success(emptyList()))
|
||||
var before: DomainResult<List<ActivityEvent>> = DomainResult.Success(emptyList())
|
||||
|
||||
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> = latest
|
||||
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = this.before
|
||||
override suspend fun append(event: ActivityEvent): DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user