feat(ui): complete app experience redesign

This commit is contained in:
2026-08-27 11:45:30 +08:00
parent 816606234b
commit 54aa74126f
70 changed files with 4510 additions and 985 deletions
@@ -1,45 +0,0 @@
package com.flagship.abox.manager.automation.activity
import com.flagship.abox.manager.domain.ActivityEvent
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.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ActivityEventWriter(
private val repository: ActivityRepository,
private val scope: CoroutineScope,
) {
fun recordDevice(
profileId: ProviderProfileId,
deviceName: DeviceName,
kind: ActivityEventKind,
outcome: ActivityOutcome,
errorCode: String? = null,
) {
scope.launch {
runCatching {
repository.append(
ActivityEvent(
id = ActivityEventId(UUID.randomUUID().toString()),
profileId = profileId,
occurredAtEpochMillis = System.currentTimeMillis(),
kind = kind,
source = ActivitySource.USER,
outcome = outcome,
target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value),
errorCode = errorCode,
),
)
}
}
}
}
@@ -37,13 +37,25 @@ private fun ActivityEventEntity.toDomain() = ActivityEvent(
id = ActivityEventId(id),
profileId = ProviderProfileId(profileId),
occurredAtEpochMillis = occurredAtEpochMillis,
kind = ActivityEventKind.valueOf(kind),
source = ActivitySource.valueOf(source),
outcome = ActivityOutcome.valueOf(outcome),
target = ActivityTarget(ActivityTargetType.valueOf(targetType), targetStableId, targetDisplayName),
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,
@@ -6,6 +6,7 @@ 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
@@ -15,6 +16,9 @@ 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)
@@ -28,6 +32,11 @@ object AutomationModule {
@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())
@@ -5,6 +5,7 @@ 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
@@ -25,7 +26,9 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
)
return try {
NotificationManagerCompat.from(context).notify(
@SuppressLint("MissingPermission")
fun postNotification() {
NotificationManagerCompat.from(context).notify(
profileId.value.hashCode(),
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
@@ -33,7 +36,9 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat
.setContentText(message)
.setAutoCancel(true)
.build(),
)
)
}
postNotification()
DomainResult.Success(Unit)
} catch (_: SecurityException) {
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
@@ -3,18 +3,26 @@ 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
@@ -27,16 +35,19 @@ fun ActivityScreen(
zoneId: ZoneId = ZoneId.systemDefault(),
) {
Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("活动记录", style = MaterialTheme.typography.headlineSmall)
Text(stringResource(R.string.activity_title), style = MaterialTheme.typography.headlineSmall)
if (!state.loading && state.groups.isEmpty()) {
Text("暂无活动记录", modifier = Modifier.testTag("activity_empty"))
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 }) { event -> ActivityEventRow(event, zoneId) }
items(group.events, key = { it.id.value }) { event -> ActivityEventRow(event, zoneId) }
}
if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text("加载更早记录") } }
if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text(stringResource(R.string.activity_load_earlier)) } }
}
}
}
@@ -45,13 +56,39 @@ fun ActivityScreen(
@Composable
private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) {
val outcome = when (event.outcome) {
ActivityOutcome.SUCCEEDED -> "成功"
ActivityOutcome.FAILED -> "失败"
ActivityOutcome.CANCELLED -> "已取消"
ActivityOutcome.AWAITING_CONFIRMATION -> "等待确认"
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
}
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"))}")
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) }
}
}
}
@@ -15,6 +15,7 @@ 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
@@ -43,12 +44,14 @@ class ActivityViewModel @Inject constructor(
observation?.cancel()
_state.value = ActivityUiState(profileId = profileId)
observation = viewModelScope.launch {
repository.observeLatest(profileId, 100).collectLatest { result ->
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)
}
}
}
}
}
@@ -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>