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:
2026-08-27 23:25:07 +08:00
committed by KremeCN
parent 1570a95f30
commit 86b7e5849b
194 changed files with 11198 additions and 1444 deletions
@@ -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, "设备"),
)
}
@@ -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
}
@@ -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
}
@@ -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,
)
@@ -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,
)
@@ -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"))
}
}
@@ -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)
}
@@ -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" }
}
@@ -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()
}
}
}
}
@@ -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()
}
@@ -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)
}
@@ -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" }
}
@@ -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)
}
@@ -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) }
}
}
}
@@ -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>
@@ -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)
}
}