From 7afcea296ea50cc3fcc7b76f1741d6d33b7380fd Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:44:15 +0800 Subject: [PATCH] feat: add persisted activity timeline --- .../ui/navigation/AuthenticatedNavigation.kt | 13 +- android/feature/automation/build.gradle.kts | 11 ++ .../automation/ui/activity/ActivityRoute.kt | 15 +++ .../automation/ui/activity/ActivityScreen.kt | 57 +++++++++ .../ui/activity/ActivityViewModel.kt | 87 ++++++++++++++ .../ui/activity/ActivityViewModelTest.kt | 113 ++++++++++++++++++ 6 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt create mode 100644 android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index beb6bac..220a30d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -41,6 +41,7 @@ import com.flagship.abox.manager.infrared.hub.InfraredHubRoute import com.flagship.abox.manager.infrared.panel.RemotePanelRoute import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute +import com.flagship.abox.manager.automation.ui.activity.ActivityRoute import com.flagship.abox.manager.ui.scene.SceneListScreen import com.flagship.abox.manager.ui.preferences.SettingsRoute import com.flagship.abox.manager.ui.scene.SceneRoutes @@ -350,7 +351,7 @@ private fun AuthenticatedNavHost( ) } composable(MainTab.ACTIVITY.route) { - ActivityDestination() + ActivityRoute(profileId = profile.id) } composable(Routes.SETTINGS) { SettingsRoute() @@ -457,16 +458,6 @@ private fun DeviceDetailPane( } } -@Composable -private fun ActivityDestination() { - Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text( - text = androidx.compose.ui.res.stringResource(R.string.activity_placeholder_title), - style = MaterialTheme.typography.headlineMedium, - ) - } -} - private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? { val parts = route?.split('/') ?: return null return when { diff --git a/android/feature/automation/build.gradle.kts b/android/feature/automation/build.gradle.kts index e0eab99..e7fce16 100644 --- a/android/feature/automation/build.gradle.kts +++ b/android/feature/automation/build.gradle.kts @@ -1,6 +1,7 @@ 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) } @@ -14,6 +15,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { compose = true } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -36,6 +39,14 @@ dependencies { 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) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt new file mode 100644 index 0000000..790c1cf --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt @@ -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) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt new file mode 100644 index 0000000..0518550 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt @@ -0,0 +1,57 @@ +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.unit.dp +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityOutcome +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("活动记录", style = MaterialTheme.typography.headlineSmall) + if (!state.loading && state.groups.isEmpty()) { + Text("暂无活动记录", 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 }) { event -> ActivityEventRow(event, zoneId) } + } + if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text("加载更早记录") } } + } + } + } +} + +@Composable +private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) { + val outcome = when (event.outcome) { + ActivityOutcome.SUCCEEDED -> "成功" + ActivityOutcome.FAILED -> "失败" + ActivityOutcome.CANCELLED -> "已取消" + ActivityOutcome.AWAITING_CONFIRMATION -> "等待确认" + } + Column(modifier = Modifier.fillMaxSize().testTag("activity_event_${event.id.value}")) { + Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge) + Text("$outcome · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}") + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt new file mode 100644 index 0000000..23cafe1 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt @@ -0,0 +1,87 @@ +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.launch +import javax.inject.Inject + +data class ActivityGroup(val label: String, val events: List) + +data class ActivityUiState( + val profileId: ProviderProfileId? = null, + val events: List = emptyList(), + val groups: List = 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 = _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).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, zoneId: ZoneId) { + val sorted = events.sortedWith(compareByDescending { 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>): Boolean = + result is DomainResult.Success && result.value.size >= 100 +} diff --git a/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt b/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt new file mode 100644 index 0000000..8bdac86 --- /dev/null +++ b/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt @@ -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.Success(emptyList())) + var before: DomainResult> = DomainResult.Success(emptyList()) + + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = latest + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = this.before + override suspend fun append(event: ActivityEvent): DomainResult = DomainResult.Success(Unit) + } +}