feat: add animation effects and visual feedback

This commit is contained in:
Nedifinita
2025-07-22 23:18:50 +08:00
parent 799410eeb2
commit 3edc8632be
4 changed files with 428 additions and 140 deletions

View File

@@ -20,20 +20,31 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import kotlinx.coroutines.delay
@@ -966,55 +977,165 @@ fun ConnectionDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("连接设备") },
title = {
Text(
text = "蓝牙设备",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = onScan,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = if (isScanning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
) {
Text(if (isScanning) "停止扫描" else "扫描设备")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null
)
}
Text(
text = if (isScanning) "扫描中..." else "扫描设备",
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(16.dp))
if (isScanning) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.height(8.dp))
}
if (devices.isEmpty()) {
Text("未找到设备")
} else {
Column {
devices.forEach { device ->
if (devices.isNotEmpty()) {
Text(
text = "发现 ${devices.size} 个设备",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(devices) { device ->
var isPressed by remember { mutableStateOf(false) }
val cardScale by animateFloatAsState(
targetValue = if (isPressed) 0.98f else 1f,
animationSpec = tween(
durationMillis = 120,
easing = LinearEasing
)
)
LaunchedEffect(isPressed) {
if (isPressed) {
delay(100)
isPressed = false
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onConnect(device) }
.graphicsLayer {
scaleX = cardScale
scaleY = cardScale
},
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(8.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true)
) {
isPressed = true
onConnect(device)
}
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = device.name ?: "未知设备",
fontWeight = FontWeight.Bold
)
Text(
text = device.address,
style = MaterialTheme.typography.bodySmall
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = device.name ?: "未知设备",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = device.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
} else if (!isScanning) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.BluetoothSearching,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "未发现设备",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "请确保设备已开启并处于可发现状态",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("取消")
Text("关闭")
}
}
)

View File

@@ -1,18 +1,26 @@
package org.noxylva.lbjconsole.ui.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -21,6 +29,7 @@ import org.noxylva.lbjconsole.model.TrainRecord
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TrainRecordsList(
records: List<TrainRecord>,
@@ -41,19 +50,52 @@ fun TrainRecordsList(
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp)
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(records) { record ->
itemsIndexed(records, key = { _, record -> record.uniqueId }) { index, record ->
val animationDelay = (index * 30).coerceAtMost(200)
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.98f else 1f,
animationSpec = tween(durationMillis = 120)
)
val elevation by animateDpAsState(
targetValue = if (isPressed) 6.dp else 2.dp,
animationSpec = tween(durationMillis = 120)
)
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(150)
isPressed = false
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable { onRecordClick(record) },
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.animateItemPlacement(
animationSpec = tween(durationMillis = 200)
),
elevation = CardDefaults.cardElevation(defaultElevation = elevation)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true)
) {
isPressed = true
onRecordClick(record)
}
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically

View File

@@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
@@ -19,6 +22,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -53,14 +57,41 @@ fun TrainRecordItem(
onToggleSelection: (TrainRecord) -> Unit,
onLongClick: (TrainRecord) -> Unit
) {
val cardColor = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
}
val recordId = record.uniqueId
val isExpanded = expandedStatesMap[recordId] == true
val cardColor by animateColorAsState(
targetValue = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
},
animationSpec = tween(durationMillis = 200),
label = "cardColor"
)
val cardScale by animateFloatAsState(
targetValue = if (isSelected) 0.98f else 1f,
animationSpec = tween(durationMillis = 150),
label = "cardScale"
)
val cardElevation by animateDpAsState(
targetValue = if (isSelected) 6.dp else 2.dp,
animationSpec = tween(durationMillis = 200),
label = "cardElevation"
)
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = cardScale
scaleY = cardScale
}
.animateContentSize(
animationSpec = tween(durationMillis = 150, easing = LinearEasing)
),
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
colors = CardDefaults.cardColors(
containerColor = cardColor
),
@@ -260,106 +291,112 @@ fun TrainRecordItem(
}
}
if (isExpanded) {
val coordinates = remember { record.getCoordinates() }
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(animationSpec = tween(durationMillis = 300)) + fadeIn(animationSpec = tween(durationMillis = 300)),
exit = shrinkVertically(animationSpec = tween(durationMillis = 300)) + fadeOut(animationSpec = tween(durationMillis = 300))
) {
Column {
val coordinates = remember { record.getCoordinates() }
if (coordinates != null) {
Spacer(modifier = Modifier.height(8.dp))
}
if (coordinates != null) {
Spacer(modifier = Modifier.height(8.dp))
}
if (coordinates != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
AndroidView(
modifier = Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {},
factory = { context ->
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
isHorizontalMapRepetitionEnabled = false
isVerticalMapRepetitionEnabled = false
setHasTransientState(true)
setOnTouchListener { v, event ->
v.parent?.requestDisallowInterceptTouchEvent(true)
false
}
controller.setZoom(10.0)
controller.setCenter(coordinates)
this.isTilesScaledToDpi = true
this.setUseDataConnection(true)
if (coordinates != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
AndroidView(
modifier = Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {},
factory = { context ->
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
isHorizontalMapRepetitionEnabled = false
isVerticalMapRepetitionEnabled = false
setHasTransientState(true)
setOnTouchListener { v, event ->
v.parent?.requestDisallowInterceptTouchEvent(true)
false
}
controller.setZoom(10.0)
controller.setCenter(coordinates)
this.isTilesScaledToDpi = true
this.setUseDataConnection(true)
try {
val railwayTileSource = XYTileSource(
"OpenRailwayMap", 8, 16, 256, ".png",
arrayOf(
"https://a.tiles.openrailwaymap.org/standard/",
"https://b.tiles.openrailwaymap.org/standard/",
"https://c.tiles.openrailwaymap.org/standard/"
),
"© OpenRailwayMap contributors, © OpenStreetMap contributors"
)
try {
val railwayTileSource = XYTileSource(
"OpenRailwayMap", 8, 16, 256, ".png",
arrayOf(
"https://a.tiles.openrailwaymap.org/standard/",
"https://b.tiles.openrailwaymap.org/standard/",
"https://c.tiles.openrailwaymap.org/standard/"
),
"© OpenRailwayMap contributors, © OpenStreetMap contributors"
)
val railwayProvider = MapTileProviderBasic(context)
railwayProvider.tileSource = railwayTileSource
val railwayProvider = MapTileProviderBasic(context)
railwayProvider.tileSource = railwayTileSource
val railwayOverlay = TilesOverlay(railwayProvider, context)
railwayOverlay.loadingBackgroundColor = android.graphics.Color.TRANSPARENT
railwayOverlay.loadingLineColor = android.graphics.Color.TRANSPARENT
val railwayOverlay = TilesOverlay(railwayProvider, context)
railwayOverlay.loadingBackgroundColor = android.graphics.Color.TRANSPARENT
railwayOverlay.loadingLineColor = android.graphics.Color.TRANSPARENT
overlays.add(railwayOverlay)
} catch (e: Exception) {
e.printStackTrace()
}
try {
val locationProvider = GpsMyLocationProvider(context).apply {
locationUpdateMinDistance = 10f
locationUpdateMinTime = 1000
overlays.add(railwayOverlay)
} catch (e: Exception) {
e.printStackTrace()
}
MyLocationNewOverlay(locationProvider, this).apply {
enableMyLocation()
}.also { overlays.add(it) }
} catch (e: Exception) {
e.printStackTrace()
try {
val locationProvider = GpsMyLocationProvider(context).apply {
locationUpdateMinDistance = 10f
locationUpdateMinTime = 1000
}
MyLocationNewOverlay(locationProvider, this).apply {
enableMyLocation()
}.also { overlays.add(it) }
} catch (e: Exception) {
e.printStackTrace()
}
val marker = Marker(this)
marker.position = coordinates
val latStr = String.format("%.4f", coordinates.latitude)
val lonStr = String.format("%.4f", coordinates.longitude)
val coordStr = "${latStr}°N, ${lonStr}°E"
marker.title = recordMap["train"]?.toString() ?: "列车"
marker.snippet = coordStr
marker.setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
overlays.add(marker)
marker.showInfoWindow()
}
val marker = Marker(this)
marker.position = coordinates
val latStr = String.format("%.4f", coordinates.latitude)
val lonStr = String.format("%.4f", coordinates.longitude)
val coordStr = "${latStr}°N, ${lonStr}°E"
marker.title = recordMap["train"]?.toString() ?: "列车"
marker.snippet = coordStr
marker.setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
overlays.add(marker)
marker.showInfoWindow()
}
},
update = { mapView -> mapView.invalidate() }
},
update = { mapView -> mapView.invalidate() }
)
}
}
if (recordMap.containsKey("position_info")) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = recordMap["position_info"] ?: "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
if (recordMap.containsKey("position_info")) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = recordMap["position_info"] ?: "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@@ -383,14 +420,39 @@ fun MergedTrainRecordItem(
val latestRecord = mergedRecord.latestRecord
val hasSelectedRecords = mergedRecord.records.any { selectedRecords.contains(it) }
val cardColor = when {
hasSelectedRecords -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
}
val cardColor by animateColorAsState(
targetValue = when {
hasSelectedRecords -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
},
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
label = "mergedCardColor"
)
val cardScale by animateFloatAsState(
targetValue = if (hasSelectedRecords) 0.98f else 1f,
animationSpec = tween(durationMillis = 150, easing = LinearEasing),
label = "mergedCardScale"
)
val cardElevation by animateDpAsState(
targetValue = if (hasSelectedRecords) 6.dp else 2.dp,
animationSpec = tween(durationMillis = 200, easing = LinearEasing),
label = "mergedCardElevation"
)
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = cardScale
scaleY = cardScale
}
.animateContentSize(
animationSpec = tween(durationMillis = 150, easing = LinearEasing)
),
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
colors = CardDefaults.cardColors(
containerColor = cardColor
),
@@ -948,7 +1010,13 @@ fun HistoryScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
) {
items(filteredRecords) { item ->
itemsIndexed(filteredRecords, key = { _, item ->
when (item) {
is TrainRecord -> item.uniqueId
is MergedTrainRecord -> item.groupKey
else -> item.hashCode()
}
}) { index, item ->
when (item) {
is TrainRecord -> {
TrainRecordItem(

View File

@@ -1,13 +1,18 @@
package org.noxylva.lbjconsole.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -28,6 +33,20 @@ fun MonitorScreen(
) {
var showDetailDialog by remember { mutableStateOf(false) }
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.98f else 1f,
animationSpec = tween(durationMillis = 120),
label = "content_scale"
)
LaunchedEffect(isPressed) {
if (isPressed) {
delay(100)
isPressed = false
}
}
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
@@ -76,20 +95,57 @@ fun MonitorScreen(
.fillMaxWidth()
.weight(1f)
) {
if (latestRecord != null) {
AnimatedContent(
targetState = latestRecord,
transitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
) + slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 150,
easing = FastOutLinearInEasing
)
) + slideOutVertically(
targetOffsetY = { -it / 4 },
animationSpec = tween(
durationMillis = 150,
easing = FastOutLinearInEasing
)
)
},
label = "content_animation"
) { record ->
if (record != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable {
selectedRecord = latestRecord
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true)
) {
isPressed = true
selectedRecord = record
showDetailDialog = true
onRecordClick(latestRecord)
onRecordClick(record)
}
.padding(8.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
) {
val recordMap = latestRecord.toMap()
val recordMap = record.toMap()
Row(
@@ -209,6 +265,7 @@ fun MonitorScreen(
}
}
}
}
}
}