feat: add persisted activity timeline
This commit is contained in:
@@ -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)
|
||||
|
||||
+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)
|
||||
}
|
||||
+57
@@ -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"))}")
|
||||
}
|
||||
}
|
||||
+87
@@ -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<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).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
|
||||
}
|
||||
+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