feat: add animation effects and visual feedback
This commit is contained in:
@@ -20,20 +20,31 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
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.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.LocationOn
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -966,55 +977,165 @@ fun ConnectionDialog(
|
|||||||
) {
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("连接设备") },
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "蓝牙设备",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onScan,
|
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) {
|
if (devices.isNotEmpty()) {
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
Text(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
text = "发现 ${devices.size} 个设备",
|
||||||
}
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
if (devices.isEmpty()) {
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
Text("未找到设备")
|
)
|
||||||
} else {
|
|
||||||
Column {
|
LazyColumn(
|
||||||
devices.forEach { device ->
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.graphicsLayer {
|
||||||
.clickable { onConnect(device) }
|
scaleX = cardScale
|
||||||
|
scaleY = cardScale
|
||||||
|
},
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier.padding(8.dp)
|
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(
|
Icon(
|
||||||
text = device.name ?: "未知设备",
|
imageVector = Icons.Default.Bluetooth,
|
||||||
fontWeight = FontWeight.Bold
|
contentDescription = null,
|
||||||
)
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
Text(
|
modifier = Modifier.size(20.dp)
|
||||||
text = device.address,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("取消")
|
Text("关闭")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
package org.noxylva.lbjconsole.ui.components
|
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.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.FilterList
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -21,6 +29,7 @@ import org.noxylva.lbjconsole.model.TrainRecord
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TrainRecordsList(
|
fun TrainRecordsList(
|
||||||
records: List<TrainRecord>,
|
records: List<TrainRecord>,
|
||||||
@@ -41,19 +50,52 @@ fun TrainRecordsList(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 2.dp)
|
.graphicsLayer {
|
||||||
.clickable { onRecordClick(record) },
|
scaleX = scale
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
scaleY = scale
|
||||||
|
}
|
||||||
|
.animateItemPlacement(
|
||||||
|
animationSpec = tween(durationMillis = 200)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = elevation)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = rememberRipple(bounded = true)
|
||||||
|
) {
|
||||||
|
isPressed = true
|
||||||
|
onRecordClick(record)
|
||||||
|
}
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
@@ -19,6 +22,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -53,14 +57,41 @@ fun TrainRecordItem(
|
|||||||
onToggleSelection: (TrainRecord) -> Unit,
|
onToggleSelection: (TrainRecord) -> Unit,
|
||||||
onLongClick: (TrainRecord) -> Unit
|
onLongClick: (TrainRecord) -> Unit
|
||||||
) {
|
) {
|
||||||
val cardColor = when {
|
val recordId = record.uniqueId
|
||||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
val isExpanded = expandedStatesMap[recordId] == true
|
||||||
else -> MaterialTheme.colorScheme.surface
|
|
||||||
}
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = cardScale
|
||||||
|
scaleY = cardScale
|
||||||
|
}
|
||||||
|
.animateContentSize(
|
||||||
|
animationSpec = tween(durationMillis = 150, easing = LinearEasing)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = cardColor
|
containerColor = cardColor
|
||||||
),
|
),
|
||||||
@@ -260,106 +291,112 @@ fun TrainRecordItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExpanded) {
|
AnimatedVisibility(
|
||||||
val coordinates = remember { record.getCoordinates() }
|
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) {
|
if (coordinates != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coordinates != null) {
|
if (coordinates != null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(220.dp)
|
.height(220.dp)
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
indication = null,
|
indication = null,
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
) {},
|
) {},
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
MapView(context).apply {
|
MapView(context).apply {
|
||||||
setTileSource(TileSourceFactory.MAPNIK)
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
setMultiTouchControls(true)
|
setMultiTouchControls(true)
|
||||||
zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
|
zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
|
||||||
isHorizontalMapRepetitionEnabled = false
|
isHorizontalMapRepetitionEnabled = false
|
||||||
isVerticalMapRepetitionEnabled = false
|
isVerticalMapRepetitionEnabled = false
|
||||||
setHasTransientState(true)
|
setHasTransientState(true)
|
||||||
setOnTouchListener { v, event ->
|
setOnTouchListener { v, event ->
|
||||||
v.parent?.requestDisallowInterceptTouchEvent(true)
|
v.parent?.requestDisallowInterceptTouchEvent(true)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
controller.setZoom(10.0)
|
controller.setZoom(10.0)
|
||||||
controller.setCenter(coordinates)
|
controller.setCenter(coordinates)
|
||||||
this.isTilesScaledToDpi = true
|
this.isTilesScaledToDpi = true
|
||||||
this.setUseDataConnection(true)
|
this.setUseDataConnection(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val railwayTileSource = XYTileSource(
|
val railwayTileSource = XYTileSource(
|
||||||
"OpenRailwayMap", 8, 16, 256, ".png",
|
"OpenRailwayMap", 8, 16, 256, ".png",
|
||||||
arrayOf(
|
arrayOf(
|
||||||
"https://a.tiles.openrailwaymap.org/standard/",
|
"https://a.tiles.openrailwaymap.org/standard/",
|
||||||
"https://b.tiles.openrailwaymap.org/standard/",
|
"https://b.tiles.openrailwaymap.org/standard/",
|
||||||
"https://c.tiles.openrailwaymap.org/standard/"
|
"https://c.tiles.openrailwaymap.org/standard/"
|
||||||
),
|
),
|
||||||
"© OpenRailwayMap contributors, © OpenStreetMap contributors"
|
"© OpenRailwayMap contributors, © OpenStreetMap contributors"
|
||||||
)
|
)
|
||||||
|
|
||||||
val railwayProvider = MapTileProviderBasic(context)
|
val railwayProvider = MapTileProviderBasic(context)
|
||||||
railwayProvider.tileSource = railwayTileSource
|
railwayProvider.tileSource = railwayTileSource
|
||||||
|
|
||||||
val railwayOverlay = TilesOverlay(railwayProvider, context)
|
val railwayOverlay = TilesOverlay(railwayProvider, context)
|
||||||
railwayOverlay.loadingBackgroundColor = android.graphics.Color.TRANSPARENT
|
railwayOverlay.loadingBackgroundColor = android.graphics.Color.TRANSPARENT
|
||||||
railwayOverlay.loadingLineColor = android.graphics.Color.TRANSPARENT
|
railwayOverlay.loadingLineColor = android.graphics.Color.TRANSPARENT
|
||||||
|
|
||||||
overlays.add(railwayOverlay)
|
overlays.add(railwayOverlay)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val locationProvider = GpsMyLocationProvider(context).apply {
|
|
||||||
locationUpdateMinDistance = 10f
|
|
||||||
locationUpdateMinTime = 1000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MyLocationNewOverlay(locationProvider, this).apply {
|
try {
|
||||||
enableMyLocation()
|
val locationProvider = GpsMyLocationProvider(context).apply {
|
||||||
}.also { overlays.add(it) }
|
locationUpdateMinDistance = 10f
|
||||||
} catch (e: Exception) {
|
locationUpdateMinTime = 1000
|
||||||
e.printStackTrace()
|
}
|
||||||
|
|
||||||
|
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)
|
update = { mapView -> mapView.invalidate() }
|
||||||
marker.position = coordinates
|
)
|
||||||
|
}
|
||||||
val latStr = String.format("%.4f", coordinates.latitude)
|
}
|
||||||
val lonStr = String.format("%.4f", coordinates.longitude)
|
if (recordMap.containsKey("position_info")) {
|
||||||
val coordStr = "${latStr}°N, ${lonStr}°E"
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
marker.title = recordMap["train"]?.toString() ?: "列车"
|
Text(
|
||||||
marker.snippet = coordStr
|
text = recordMap["position_info"] ?: "",
|
||||||
marker.setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
overlays.add(marker)
|
|
||||||
marker.showInfoWindow()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,14 +420,39 @@ fun MergedTrainRecordItem(
|
|||||||
val latestRecord = mergedRecord.latestRecord
|
val latestRecord = mergedRecord.latestRecord
|
||||||
|
|
||||||
val hasSelectedRecords = mergedRecord.records.any { selectedRecords.contains(it) }
|
val hasSelectedRecords = mergedRecord.records.any { selectedRecords.contains(it) }
|
||||||
val cardColor = when {
|
|
||||||
hasSelectedRecords -> MaterialTheme.colorScheme.primaryContainer
|
val cardColor by animateColorAsState(
|
||||||
else -> MaterialTheme.colorScheme.surface
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = cardScale
|
||||||
|
scaleY = cardScale
|
||||||
|
}
|
||||||
|
.animateContentSize(
|
||||||
|
animationSpec = tween(durationMillis = 150, easing = LinearEasing)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = cardColor
|
containerColor = cardColor
|
||||||
),
|
),
|
||||||
@@ -948,7 +1010,13 @@ fun HistoryScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.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) {
|
when (item) {
|
||||||
is TrainRecord -> {
|
is TrainRecord -> {
|
||||||
TrainRecordItem(
|
TrainRecordItem(
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package org.noxylva.lbjconsole.ui.screens
|
package org.noxylva.lbjconsole.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -28,6 +33,20 @@ fun MonitorScreen(
|
|||||||
) {
|
) {
|
||||||
var showDetailDialog by remember { mutableStateOf(false) }
|
var showDetailDialog by remember { mutableStateOf(false) }
|
||||||
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
|
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) }
|
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
|
||||||
@@ -76,20 +95,57 @@ fun MonitorScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(8.dp)
|
.clickable(
|
||||||
.clickable {
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
selectedRecord = latestRecord
|
indication = rememberRipple(bounded = true)
|
||||||
|
) {
|
||||||
|
isPressed = true
|
||||||
|
selectedRecord = record
|
||||||
showDetailDialog = true
|
showDetailDialog = true
|
||||||
onRecordClick(latestRecord)
|
onRecordClick(record)
|
||||||
|
}
|
||||||
|
.padding(8.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val recordMap = latestRecord.toMap()
|
val recordMap = record.toMap()
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -209,6 +265,7 @@ fun MonitorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user