feat(ui): complete app experience redesign
This commit is contained in:
-45
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -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,
|
||||
|
||||
+9
@@ -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())
|
||||
|
||||
+7
-2
@@ -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"))
|
||||
|
||||
+48
-11
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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>
|
||||
Reference in New Issue
Block a user