feat(ui): complete ABox app experience redesign #10
+2
-11
@@ -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.panel.RemotePanelRoute
|
||||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
||||||
import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute
|
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.scene.SceneListScreen
|
||||||
import com.flagship.abox.manager.ui.preferences.SettingsRoute
|
import com.flagship.abox.manager.ui.preferences.SettingsRoute
|
||||||
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
||||||
@@ -350,7 +351,7 @@ private fun AuthenticatedNavHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(MainTab.ACTIVITY.route) {
|
composable(MainTab.ACTIVITY.route) {
|
||||||
ActivityDestination()
|
ActivityRoute(profileId = profile.id)
|
||||||
}
|
}
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsRoute()
|
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? {
|
private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? {
|
||||||
val parts = route?.split('/') ?: return null
|
val parts = route?.split('/') ?: return null
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.kapt)
|
alias(libs.plugins.kotlin.kapt)
|
||||||
alias(libs.plugins.hilt.android)
|
alias(libs.plugins.hilt.android)
|
||||||
}
|
}
|
||||||
@@ -14,6 +15,8 @@ android {
|
|||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures { compose = true }
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -36,6 +39,14 @@ dependencies {
|
|||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
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)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.test.runner)
|
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