feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
6 changed files with 285 additions and 11 deletions
Showing only changes of commit 7afcea296e - Show all commits
@@ -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)
@@ -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,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"))}")
}
}
@@ -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
}
@@ -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)
}
}