Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd378990fe | ||
|
|
a23bf34ccf | ||
|
|
a724c0823d | ||
|
|
cda6cf8583 | ||
|
|
c09e87fa4e | ||
|
|
0bcf73e1f9 | ||
|
|
ab1a0c0891 | ||
|
|
1a55241791 | ||
|
|
9d437a90c9 | ||
|
|
5351afe508 | ||
|
|
c80e351212 | ||
|
|
916a0d0f0a | ||
|
|
a333192df5 | ||
|
|
38cf7a3569 | ||
|
|
760a26b007 | ||
|
|
a7b41c95c7 |
No files matched your search
@@ -17,16 +17,23 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -53,6 +60,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -86,6 +96,7 @@ import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
@@ -137,7 +148,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.Map, Screen.Settings)
|
||||
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.Map, Screen.Settings)
|
||||
.sortedBy { screen ->
|
||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||
@@ -147,6 +158,19 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
|
||||
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
|
||||
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
||||
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
||||
val mainNavItems = remember(allNavItems, subOrder) {
|
||||
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
||||
}
|
||||
val moreNavItems = remember(allNavItems, subOrder) {
|
||||
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
var moreExpanded by remember { mutableStateOf(false) }
|
||||
// 更多菜单打开时拦截返回: 先关菜单
|
||||
BackHandler(enabled = moreExpanded) { moreExpanded = false }
|
||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||
val mutualViewModel: MutualViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
@@ -161,13 +185,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
) {
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
navItems.forEach { screen ->
|
||||
mainNavItems.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
@@ -178,11 +203,26 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) return@item
|
||||
moreExpanded = false
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
)
|
||||
}
|
||||
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
|
||||
if (moreNavItems.isNotEmpty()) {
|
||||
item(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
|
||||
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
|
||||
selected = moreExpanded,
|
||||
onClick = { moreExpanded = !moreExpanded }
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
@@ -193,108 +233,133 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
predictivePopTransitionSpec = { fadeTransition },
|
||||
entryDecorators = listOf(
|
||||
// Required for saving Compose state per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
// Required for ViewModel scoping per entry
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Satellites> {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
Box {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
predictivePopTransitionSpec = { fadeTransition },
|
||||
entryDecorators = listOf(
|
||||
// Required for saving Compose state per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
// Required for ViewModel scoping per entry
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Satellites> {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
viewModel = mutualViewModel,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Roaming> {
|
||||
RoamingScreen()
|
||||
}
|
||||
entry<Screen.CwDecode> {
|
||||
CwDecodeScreen()
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
}
|
||||
)
|
||||
// Radio tracking status banner
|
||||
if (trackingState.isActive) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1f, targetValue = 0.4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
), label = "pulseAlpha"
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
entry<Screen.Radar> {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
viewModel = mutualViewModel,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Roaming> {
|
||||
RoamingScreen()
|
||||
}
|
||||
entry<Screen.CwDecode> {
|
||||
CwDecodeScreen()
|
||||
}
|
||||
entry<Screen.WavelogLog> {
|
||||
WavelogLogScreen(queue = container.wavelogQueue)
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
}
|
||||
)
|
||||
// Radio tracking status banner
|
||||
if (trackingState.isActive) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1f, targetValue = 0.4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
), label = "pulseAlpha"
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||
Text(
|
||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||
Text(
|
||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
|
||||
AnimatedVisibility(
|
||||
visible = moreExpanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = expandVertically(
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
MoreMenuPopup(
|
||||
items = moreNavItems,
|
||||
currentKey = currentKey,
|
||||
onDismiss = { moreExpanded = false },
|
||||
onSelect = { screen ->
|
||||
moreExpanded = false
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
|
||||
*
|
||||
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
|
||||
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
|
||||
* (MainScreen 的 AnimatedVisibility + spring)驱动。
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
|
||||
@Composable
|
||||
fun MoreMenuPopup(
|
||||
items: List<Screen>,
|
||||
currentKey: NavKey?,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (Screen) -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
|
||||
.clickable(onClick = onDismiss)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(12.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
items.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(screen) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = stringResource(screen.titleResId),
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(screen.titleResId),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "›",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
||||
@@ -40,6 +40,9 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.IWavelogQueueStore
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
@@ -96,6 +99,16 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||
|
||||
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
|
||||
override val wavelogQueue: WavelogQueue by lazy {
|
||||
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
|
||||
WavelogQueue(object : IWavelogQueueStore {
|
||||
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
|
||||
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
|
||||
})
|
||||
}
|
||||
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
@@ -160,7 +173,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||
return RemoteSource(
|
||||
Dispatchers.IO, context.contentResolver,
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||
@@ -176,6 +196,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
val appPrefsFileName = "${context.packageName}_preferences"
|
||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||
return SettingsRepo(context, manager, appPreferences, appVersionName)
|
||||
}
|
||||
}
|
||||
@@ -81,14 +81,21 @@ class DatabaseRepo(
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||
if (successCount == 0) {
|
||||
throw java.io.IOException("All data sources failed to download")
|
||||
}
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||
}
|
||||
}
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
// insert parsed data into the database
|
||||
|
||||
+51
-17
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class SettingsRepo(
|
||||
private val context: android.content.Context,
|
||||
private val locationManager: LocationManager,
|
||||
private val preferences: SharedPreferences,
|
||||
override val appVersionName: String
|
||||
@@ -94,6 +95,11 @@ class SettingsRepo(
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySubMenuOrder = "subMenuOrder"
|
||||
private val keyWavelogUrl = "wavelogUrl"
|
||||
private val keyWavelogApiKey = "wavelogApiKey"
|
||||
private val keyWavelogStationId = "wavelogStationId"
|
||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||
private val separatorComma = ","
|
||||
|
||||
//region # Satellites selection settings
|
||||
@@ -181,23 +187,41 @@ class SettingsRepo(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setStationPosition(): Boolean {
|
||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||
try {
|
||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
||||
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
||||
val location = locationManager.getLastKnownLocation(providerDef)
|
||||
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
||||
println("Requesting location for $provider provider")
|
||||
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
||||
} else {
|
||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||
}
|
||||
} catch (exception: SecurityException) {
|
||||
println("No permissions were given - $exception")
|
||||
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
|
||||
override suspend fun setStationPosition(): Boolean {
|
||||
// 权限前置: 无定位权限直接失败(不吞异常)
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
println("GPS: no fine location permission")
|
||||
return false
|
||||
}
|
||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
||||
val signal = android.os.CancellationSignal()
|
||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||
// 15 秒超时
|
||||
val timeout = handler.postDelayed({
|
||||
signal.cancel()
|
||||
if (cont.isActive) cont.resume(false) { }
|
||||
}, 15_000L)
|
||||
val listener = androidx.core.util.Consumer<Location> { location ->
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||
if (cont.isActive) cont.resume(true) { }
|
||||
}
|
||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||
val provider = if (hasGps) providerGps else providerNet
|
||||
try {
|
||||
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
||||
} catch (exception: SecurityException) {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
if (cont.isActive) cont.resume(false) { }
|
||||
}
|
||||
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean {
|
||||
@@ -361,6 +385,11 @@ class SettingsRepo(
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
||||
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
||||
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
|
||||
putString(keyWavelogUrl, new.wavelogUrl)
|
||||
putString(keyWavelogApiKey, new.wavelogApiKey)
|
||||
putString(keyWavelogStationId, new.wavelogStationId)
|
||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -378,8 +407,13 @@ class SettingsRepo(
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
||||
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
||||
override fun invoke(message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun invoke(resId: Int) {
|
||||
invoke(context.getString(resId))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.coreDomainPlugin)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||
compileOnly("org.json:json:20240303")
|
||||
}
|
||||
@@ -65,8 +65,15 @@ data class OtherSettings(
|
||||
val highElevation: Double = 45.0,
|
||||
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
||||
val hiddenScreens: List<String> = emptyList(),
|
||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/匹配/漫游/地图/设置)
|
||||
val screenOrder: List<String> = emptyList()
|
||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
|
||||
val screenOrder: List<String> = emptyList(),
|
||||
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
|
||||
val subMenuOrder: List<String> = emptyList(),
|
||||
// WaveLog 日志系统(4.5.2): 服务器配置
|
||||
val wavelogUrl: String = "",
|
||||
val wavelogApiKey: String = "",
|
||||
val wavelogStationId: String = "",
|
||||
val wavelogAutoUpload: Boolean = false
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
+3
@@ -42,6 +42,9 @@ interface IMainContainer {
|
||||
fun provideRxRadioController(): IRadioController
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
fun provideSaveImage(): ISaveImage
|
||||
// WaveLog 日志(4.5.2)
|
||||
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
}
|
||||
|
||||
data class MutualPassData(
|
||||
|
||||
+2
-1
@@ -45,7 +45,8 @@ interface ISettingsRepo {
|
||||
//region # Station position settings
|
||||
val stationPosition: StateFlow<GeoPos>
|
||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||
fun setStationPosition(): Boolean
|
||||
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
|
||||
suspend fun setStationPosition(): Boolean
|
||||
fun setStationPosition(locator: String): Boolean
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
||||
|
||||
interface IShowToast {
|
||||
operator fun invoke(message: String)
|
||||
|
||||
/** 按资源 ID 显示(四语文案) */
|
||||
operator fun invoke(resId: Int)
|
||||
}
|
||||
+27
-1
@@ -11,6 +11,7 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
|
||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||
* with an offset applied to the downlink (in Hz).
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*
|
||||
* The user-entered downlink frequency already includes the offset, so subtract
|
||||
* it before mapping the downlink passband position back to the uplink.
|
||||
*/
|
||||
fun computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz: Long,
|
||||
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
}
|
||||
|
||||
@@ -95,4 +99,26 @@ object DopplerFrequencyCalculator {
|
||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||
&& upLow != upHigh && downLow != downHigh
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the radio entry that should drive the standalone Calculator page.
|
||||
*
|
||||
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||
*/
|
||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||
if (!isLinearTransponder(transponder)) return false
|
||||
|
||||
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
val hasLinearName = info.contains("linear")
|
||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||
info.contains("xponder") || info.contains("xpdr")
|
||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
|
||||
*
|
||||
* 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404):
|
||||
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段)
|
||||
* v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串)
|
||||
* 策略: 优先 v2, 404 自动降级 v1。
|
||||
* 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。
|
||||
* 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 QTH。
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
|
||||
/** 站点信息(GET /api/v2/station/{id} 结果) */
|
||||
data class WavelogStation(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val gridsquare: String
|
||||
)
|
||||
|
||||
sealed class WavelogResult {
|
||||
data class Success(val message: String) : WavelogResult()
|
||||
data class Failure(val message: String) : WavelogResult()
|
||||
}
|
||||
|
||||
object WaveLogApi {
|
||||
|
||||
private const val TIMEOUT_MS = 15000
|
||||
|
||||
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
|
||||
fun normalizeUrl(raw: String): String {
|
||||
var u = raw.trim().trimEnd('/')
|
||||
if (u.isBlank()) return ""
|
||||
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
||||
return u
|
||||
}
|
||||
|
||||
/** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */
|
||||
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
// v2: GET /index.php/api/v2/token
|
||||
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
||||
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
||||
|
||||
// v1: POST /index.php/api/get_contacts_adif(key 在 body)
|
||||
if (stationId.isNotBlank()) {
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
}
|
||||
// 无 index.php 的 v1 尝试
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
|
||||
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
||||
}
|
||||
|
||||
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */
|
||||
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
||||
if (code in 200..299) {
|
||||
return@withContext try {
|
||||
val obj = JSONObject(resp)
|
||||
val data = obj.optJSONObject("data") ?: obj
|
||||
val station = WavelogStation(
|
||||
id = data.optInt("id"),
|
||||
name = data.optString("name"),
|
||||
callsign = data.optString("callsign"),
|
||||
gridsquare = data.optString("gridsquare")
|
||||
)
|
||||
WavelogResult.Success(JSONObject().apply {
|
||||
put("id", station.id); put("name", station.name)
|
||||
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
||||
}.toString())
|
||||
} catch (e: Exception) {
|
||||
WavelogResult.Failure("解析失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
|
||||
WavelogResult.Success("")
|
||||
}
|
||||
|
||||
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
|
||||
fun normalizeSatName(raw: String): String {
|
||||
val main = raw.substringBefore('(').trim()
|
||||
.ifBlank { raw.trim() }
|
||||
.uppercase(Locale.ENGLISH)
|
||||
// ISS 特判: 主名是 ISS/ZARYA/ARISS 变体 → ISS(LoTW 认可名)
|
||||
return when {
|
||||
main == "ZARYA" || main.startsWith("ISS") -> "ISS"
|
||||
main == "ARISS" -> "ISS"
|
||||
else -> main
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */
|
||||
suspend fun postQso(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stationProfileId: String,
|
||||
qso: WavelogQso,
|
||||
gridsquare: String
|
||||
): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
val satName = normalizeSatName(qso.satName)
|
||||
|
||||
// v2: POST /index.php/api/v2/qso(JSON 字段)
|
||||
val v2Body = JSONObject().apply {
|
||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||
put("call", qso.call)
|
||||
put("band", "SAT")
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
put("freq", qso.freqTxHz)
|
||||
put("freq_rx", qso.freqRxHz)
|
||||
put("gridsquare", gridsquare)
|
||||
put("sat_name", satName)
|
||||
}
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
||||
|
||||
// v1: POST /index.php/api/qso(key 在 body + ADIF)
|
||||
val v1Body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_profile_id", stationProfileId)
|
||||
put("type", "adif")
|
||||
put("string", toAdif(qso, gridsquare, satName))
|
||||
}
|
||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||
|
||||
// 无 index.php 的 v1
|
||||
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||
|
||||
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
||||
}
|
||||
|
||||
/** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
|
||||
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
fun field(name: String, value: String): String {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||
return "<$name:$bytes>$value"
|
||||
}
|
||||
return buildString {
|
||||
append(field("call", qso.call))
|
||||
append(field("band", "SAT"))
|
||||
append(field("mode", qso.mode))
|
||||
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
if (qso.freqRxHz > 0) {
|
||||
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||
}
|
||||
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
||||
if (satName.isNotBlank()) {
|
||||
append(field("sat_name", satName))
|
||||
append(field("prop_mode", "SAT"))
|
||||
}
|
||||
append("<eor>")
|
||||
}
|
||||
}
|
||||
|
||||
/** 通用 HTTP 请求(返回 code + body) */
|
||||
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
||||
return try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = method
|
||||
conn.connectTimeout = TIMEOUT_MS
|
||||
conn.readTimeout = TIMEOUT_MS
|
||||
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
|
||||
if (jsonBody != null) {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||
val body = if (stream != null) {
|
||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||
} else ""
|
||||
code to body
|
||||
} catch (e: Exception) {
|
||||
-1 to (e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortError(body: String): String {
|
||||
if (body.startsWith("<")) return body.take(80) // HTML 错误页
|
||||
return try {
|
||||
val obj = JSONObject(body)
|
||||
val err = obj.optJSONObject("error")
|
||||
err?.optString("message")?.ifBlank { body.take(120) }
|
||||
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcDate(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d-%02d-%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcTime(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d:%02d:%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcDateCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcTimeCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
|
||||
*
|
||||
* 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
|
||||
* 由 core/data 用 SharedPreferences 实现。
|
||||
* 队列上限 500 条(超出丢最旧)。
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** 存储抽象(SharedPreferences 实现见 core/data) */
|
||||
interface IWavelogQueueStore {
|
||||
fun load(): String
|
||||
fun save(json: String)
|
||||
}
|
||||
|
||||
/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
|
||||
data class WavelogQso(
|
||||
val id: String, // 本地唯一 id(UUID)
|
||||
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
||||
val call: String,
|
||||
val mode: String,
|
||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||
val freqRxHz: Long, // 下行
|
||||
val satName: String,
|
||||
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||
)
|
||||
|
||||
class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
|
||||
private val key = "wavelog_queue"
|
||||
|
||||
fun all(): List<WavelogQso> {
|
||||
val raw = store.load()
|
||||
return try {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
WavelogQso(
|
||||
id = o.getString("id"),
|
||||
timeUtcMs = o.getLong("timeUtcMs"),
|
||||
call = o.optString("call"),
|
||||
mode = o.optString("mode"),
|
||||
freqTxHz = o.optLong("freqTxHz"),
|
||||
freqRxHz = o.optLong("freqRxHz"),
|
||||
satName = o.optString("satName"),
|
||||
sessionId = o.optString("sessionId"),
|
||||
uploaded = o.optBoolean("uploaded", false)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun add(qso: WavelogQso) {
|
||||
val list = all().toMutableList()
|
||||
list.add(0, qso) // 最新在前
|
||||
if (list.size > 500) list.removeAt(list.size - 1)
|
||||
save(list)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun remove(id: String) {
|
||||
save(all().filter { it.id != id })
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeAll(ids: Set<String>) {
|
||||
save(all().filter { it.id !in ids })
|
||||
}
|
||||
|
||||
/** 标记为已上传(保留在队列, 表格打勾) */
|
||||
@Synchronized
|
||||
fun markUploaded(id: String) {
|
||||
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
||||
}
|
||||
|
||||
/** 移除所有已上传条目(可选项, 保持队列精简) */
|
||||
@Synchronized
|
||||
fun removeUploaded() {
|
||||
save(all().filter { !it.uploaded })
|
||||
}
|
||||
|
||||
private fun save(list: List<WavelogQso>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { q ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||
put("sessionId", q.sessionId)
|
||||
put("uploaded", q.uploaded)
|
||||
})
|
||||
}
|
||||
store.save(arr.toString())
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
|
||||
*
|
||||
* 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
|
||||
* → POST /api/v2/qso → 成功移出队列。
|
||||
* 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
|
||||
* 确认后带 force=true 重试本批。
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import org.json.JSONObject
|
||||
|
||||
sealed class UploadOutcome {
|
||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||
data class Done(
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val message: String,
|
||||
val firstError: String = ""
|
||||
) : UploadOutcome()
|
||||
}
|
||||
|
||||
class WavelogUploader(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val queue: WavelogQueue
|
||||
) {
|
||||
|
||||
// 台站网格缓存(每次上传前刷新; 失败用旧值)
|
||||
private var cachedStationGrid: String? = null
|
||||
|
||||
/** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
|
||||
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
|
||||
val settings = settingsRepo.otherSettings.value
|
||||
val url = settings.wavelogUrl
|
||||
val apiKey = settings.wavelogApiKey
|
||||
val stationId = settings.wavelogStationId
|
||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
||||
}
|
||||
|
||||
// 1. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
|
||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||
if (stationGrid.isNullOrBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||
}
|
||||
|
||||
// 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过)
|
||||
if (!force) {
|
||||
val userGrid = userQthGrid()
|
||||
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
||||
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾)
|
||||
val entries = queue.all()
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
var firstError = ""
|
||||
for (qso in entries) {
|
||||
if (qso.uploaded) { ok++; continue }
|
||||
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid)
|
||||
if (result is WavelogResult.Success) {
|
||||
ok++
|
||||
queue.markUploaded(qso.id)
|
||||
} else {
|
||||
fail++
|
||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||
}
|
||||
}
|
||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||
}
|
||||
|
||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
||||
if (result is WavelogResult.Success) {
|
||||
return try {
|
||||
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
||||
?: cachedStationGrid
|
||||
} catch (_: Exception) { cachedStationGrid }
|
||||
}
|
||||
return cachedStationGrid
|
||||
}
|
||||
|
||||
/** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
|
||||
private fun userQthGrid(): String? {
|
||||
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
|
||||
}
|
||||
}
|
||||
+76
-4
@@ -13,12 +13,15 @@ class DopplerFrequencyCalculatorTest {
|
||||
upHigh: Long = 145_500_000L,
|
||||
downLow: Long = 435_000_000L,
|
||||
downHigh: Long? = 435_500_000L,
|
||||
inverted: Boolean = false
|
||||
inverted: Boolean = false,
|
||||
info: String = "Linear Transponder",
|
||||
downlinkMode: String? = "USB",
|
||||
uplinkMode: String? = "LSB"
|
||||
) = SatRadio(
|
||||
uuid = "linear", info = "Linear Transponder", isAlive = true,
|
||||
uuid = "linear", info = info, isAlive = true,
|
||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
|
||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||
)
|
||||
|
||||
private fun fmTransponder() = SatRadio(
|
||||
@@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest {
|
||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||
val xpdr = linearTransponder()
|
||||
@@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest {
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz = 145_200_000L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertEquals(435_202_500L, downlink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz = 435_202_500L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz = 145_200_000L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = -2_500L
|
||||
)
|
||||
assertEquals(435_197_500L, downlink)
|
||||
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz = downlink!!,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = -2_500L
|
||||
)
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||
|
||||
+1
-2
@@ -518,6 +518,5 @@ fun formatFrequency(frequencyHz: Long): String {
|
||||
if (frequencyHz <= 0) return "---"
|
||||
val mhz = frequencyHz / 1_000_000
|
||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||
val hz = frequencyHz % 1_000
|
||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
||||
}
|
||||
+8
-2
@@ -44,12 +44,18 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
|
||||
@Serializable
|
||||
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
||||
|
||||
@Serializable
|
||||
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||
}
|
||||
|
||||
// UI 设置: 默认页面顺序(与 MainScreen navItems 一致)
|
||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "CwDecode", "Map", "Settings")
|
||||
// UI 设置: 默认主菜单页面顺序(底部栏 5 槽)
|
||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
||||
|
||||
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
|
||||
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog")
|
||||
|
||||
@Serializable
|
||||
data object RadarDestination : NavKey
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Look4Sat Pro</string>
|
||||
<string name="btn_accept">Terima</string>
|
||||
<string name="btn_cancel">Batal</string>
|
||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||
<string name="nav_sat">Satelit</string>
|
||||
<string name="nav_pass">Lintasan</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_mutual">Pasangan</string>
|
||||
<string name="nav_roaming">Jelajah</string>
|
||||
<string name="nav_cw">Dekoder CW</string>
|
||||
<string name="nav_map">Peta</string>
|
||||
<string name="nav_prefs">Pengaturan</string>
|
||||
<string name="nav_more">Lainnya</string>
|
||||
<string name="nav_log">Log</string>
|
||||
<string name="sat_type_hint">Jenis: %s</string>
|
||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||
<string name="sat_search_hint">Id - Nama</string>
|
||||
<string name="sat_search_clear">Bersihkan</string>
|
||||
<string name="sat_clear_all">Bersihkan semua</string>
|
||||
<string name="sat_select_all">Pilih semua</string>
|
||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||
<string name="sat_warning_title">Peringatan\!</string>
|
||||
<string name="sat_warning_message">
|
||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||
<string name="pass_filter_title">Filter lintasan</string>
|
||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||
<string name="pass_time_placeholder"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||
<string name="pass_satId">%05d</string>
|
||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||
<string name="pass_deep_space">DeepSpace</string>
|
||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||
<string name="pass_aosLos">%03d° - %03d°</string>
|
||||
<string name="pass_empty_list_message">
|
||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message">
|
||||
* WaveLog kini mendukung API v1 dan v2 (terdeteksi otomatis); dialog kesalahan dengan tombol salin
|
||||
* Frekuensi log cocok dengan panel Doppler; transponder linier menampilkan rentang penuh</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
<string name="radar_az_value">%.1f°</string>
|
||||
<string name="radar_el_text">Elevasi</string>
|
||||
<string name="radar_el_value">%.1f°</string>
|
||||
<string name="radar_alt_text">Ketinggian</string>
|
||||
<string name="radar_alt_value">%.0f km</string>
|
||||
<string name="radar_dist_text">Jarak</string>
|
||||
<string name="radar_dist_value">%.0f km</string>
|
||||
<string name="radar_eclipsed">Gerhana</string>
|
||||
<string name="radar_downlink">Downlink</string>
|
||||
<string name="radar_uplink">Uplink</string>
|
||||
<string name="radar_link_low">%.4f</string>
|
||||
<string name="radar_link_lowHigh">%.4f - %.4f</string>
|
||||
<string name="radar_no_link">- - . - -</string>
|
||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||
<string name="radar_add">Tambah</string>
|
||||
<string name="radar_data">Transceiver satelit:</string>
|
||||
<string name="radar_inverted">Terbalik: %s</string>
|
||||
<string name="radar_mode">Mode: %s</string>
|
||||
<string name="radar_string_no">Tidak</string>
|
||||
<string name="radar_string_yes">Ya</string>
|
||||
<string name="radar_visible">Terlihat</string>
|
||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||
<string name="radar_cw_start">Mulai</string>
|
||||
<string name="radar_cw_stop">Berhenti</string>
|
||||
<string name="radar_cw_reset">Bersihkan</string>
|
||||
<string name="map_prev">Sebelumnya</string>
|
||||
<string name="map_next">Berikutnya</string>
|
||||
<string name="map_copyright">© Kontributor OpenStreetMap</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||
<string name="map_distance">Jarak: %.0f km</string>
|
||||
<string name="map_latitude">Lintang: %.1f°</string>
|
||||
<string name="map_longitude">Bujur: %.1f°</string>
|
||||
<string name="map_qth">QTH: %s</string>
|
||||
<string name="map_phase">Fase: %.1f°</string>
|
||||
<string name="map_eclipsed">Gerhana</string>
|
||||
<string name="map_period">Periode: %.0f mnt</string>
|
||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||
<string name="map_visibility">Visibilitas: %s</string>
|
||||
<string name="map_visible">Terlihat</string>
|
||||
<string name="prefs_app_title">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Donasi</string>
|
||||
<string name="prefs_donate_url">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title">F-Droid</string>
|
||||
<string name="prefs_fdroid_url">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_github_title">GitHub</string>
|
||||
<string name="prefs_github_url">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_license_title">GPLv3</string>
|
||||
<string name="prefs_license_url">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||
<string name="prefs_privacy_url">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||
<string name="prefs_loc_input_title">Input</string>
|
||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||
<string name="prefs_data_title">Data satelit</string>
|
||||
<string name="prefs_data_entries">Satelit: %s</string>
|
||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||
<string name="prefs_data_update">Perbarui</string>
|
||||
<string name="prefs_data_import">URL Kustom</string>
|
||||
<string name="prefs_data_clear">Bersihkan</string>
|
||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||
<string name="prefs_data_sources_url_title">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||
<string name="prefs_data_output_title">Keluaran data</string>
|
||||
<string name="prefs_net_output">Jaringan</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||
<string name="rc_radio_model">Model Radio</string>
|
||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||
<string name="tracking_status">Melacak: %s</string>
|
||||
<string name="loc_lat_text">Lintang: %s°</string>
|
||||
<string name="loc_lon_text">Bujur: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">Hubungkan</string>
|
||||
<string name="disconnect_radio">Putuskan</string>
|
||||
<string name="track_radio">Lacak</string>
|
||||
<string name="stop_radio">Berhenti</string>
|
||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
||||
<string name="wavelog_title">Log</string>
|
||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
||||
<string name="wavelog_mode_hint">Mode</string>
|
||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
||||
<string name="wavelog_empty">Belum ada entri log</string>
|
||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
||||
<string name="wavelog_cancel">Batal</string>
|
||||
<string name="wavelog_copy">Salin</string>
|
||||
<string name="wavelog_delete_undo">Urungkan</string>
|
||||
<string name="wavelog_col_time">Waktu</string>
|
||||
<string name="wavelog_col_freq">Frek.</string>
|
||||
<string name="wavelog_col_sat">Satelit</string>
|
||||
<string name="wavelog_col_call">Panggilan</string>
|
||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||
<string name="prefs_outro_thanks">
|
||||
* Pengguna dan kontributor Look4Sat!
|
||||
\n* David A. B. Johnson (predict4java)
|
||||
\n* Dave Moten (predict4java)
|
||||
\n* Alexandru Csete (Gpredict)
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl dan kontributor Robot36!
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Look4Sat Pro</string>
|
||||
<string name="btn_accept">Terima</string>
|
||||
<string name="btn_cancel">Batal</string>
|
||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||
<string name="nav_sat">Satelit</string>
|
||||
<string name="nav_pass">Lintasan</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_mutual">Pasangan</string>
|
||||
<string name="nav_roaming">Jelajah</string>
|
||||
<string name="nav_cw">Dekoder CW</string>
|
||||
<string name="nav_map">Peta</string>
|
||||
<string name="nav_prefs">Pengaturan</string>
|
||||
<string name="nav_more">Lainnya</string>
|
||||
<string name="nav_log">Log</string>
|
||||
<string name="sat_type_hint">Jenis: %s</string>
|
||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||
<string name="sat_search_hint">Id - Nama</string>
|
||||
<string name="sat_search_clear">Bersihkan</string>
|
||||
<string name="sat_clear_all">Bersihkan semua</string>
|
||||
<string name="sat_select_all">Pilih semua</string>
|
||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||
<string name="sat_warning_title">Peringatan\!</string>
|
||||
<string name="sat_warning_message">
|
||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||
<string name="pass_filter_title">Filter lintasan</string>
|
||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||
<string name="pass_time_placeholder"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||
<string name="pass_satId">%05d</string>
|
||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||
<string name="pass_deep_space">DeepSpace</string>
|
||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||
<string name="pass_aosLos">%03d° - %03d°</string>
|
||||
<string name="pass_empty_list_message">
|
||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message">
|
||||
* WaveLog kini mendukung API v1 dan v2 (terdeteksi otomatis); dialog kesalahan dengan tombol salin
|
||||
* Frekuensi log cocok dengan panel Doppler; transponder linier menampilkan rentang penuh</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
<string name="radar_az_value">%.1f°</string>
|
||||
<string name="radar_el_text">Elevasi</string>
|
||||
<string name="radar_el_value">%.1f°</string>
|
||||
<string name="radar_alt_text">Ketinggian</string>
|
||||
<string name="radar_alt_value">%.0f km</string>
|
||||
<string name="radar_dist_text">Jarak</string>
|
||||
<string name="radar_dist_value">%.0f km</string>
|
||||
<string name="radar_eclipsed">Gerhana</string>
|
||||
<string name="radar_downlink">Downlink</string>
|
||||
<string name="radar_uplink">Uplink</string>
|
||||
<string name="radar_link_low">%.4f</string>
|
||||
<string name="radar_link_lowHigh">%.4f - %.4f</string>
|
||||
<string name="radar_no_link">- - . - -</string>
|
||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||
<string name="radar_add">Tambah</string>
|
||||
<string name="radar_data">Transceiver satelit:</string>
|
||||
<string name="radar_inverted">Terbalik: %s</string>
|
||||
<string name="radar_mode">Mode: %s</string>
|
||||
<string name="radar_string_no">Tidak</string>
|
||||
<string name="radar_string_yes">Ya</string>
|
||||
<string name="radar_visible">Terlihat</string>
|
||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||
<string name="radar_cw_start">Mulai</string>
|
||||
<string name="radar_cw_stop">Berhenti</string>
|
||||
<string name="radar_cw_reset">Bersihkan</string>
|
||||
<string name="map_prev">Sebelumnya</string>
|
||||
<string name="map_next">Berikutnya</string>
|
||||
<string name="map_copyright">© Kontributor OpenStreetMap</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||
<string name="map_distance">Jarak: %.0f km</string>
|
||||
<string name="map_latitude">Lintang: %.1f°</string>
|
||||
<string name="map_longitude">Bujur: %.1f°</string>
|
||||
<string name="map_qth">QTH: %s</string>
|
||||
<string name="map_phase">Fase: %.1f°</string>
|
||||
<string name="map_eclipsed">Gerhana</string>
|
||||
<string name="map_period">Periode: %.0f mnt</string>
|
||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||
<string name="map_visibility">Visibilitas: %s</string>
|
||||
<string name="map_visible">Terlihat</string>
|
||||
<string name="prefs_app_title">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Donasi</string>
|
||||
<string name="prefs_donate_url">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title">F-Droid</string>
|
||||
<string name="prefs_fdroid_url">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_github_title">GitHub</string>
|
||||
<string name="prefs_github_url">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_license_title">GPLv3</string>
|
||||
<string name="prefs_license_url">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||
<string name="prefs_privacy_url">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||
<string name="prefs_loc_input_title">Input</string>
|
||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||
<string name="prefs_data_title">Data satelit</string>
|
||||
<string name="prefs_data_entries">Satelit: %s</string>
|
||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||
<string name="prefs_data_update">Perbarui</string>
|
||||
<string name="prefs_data_import">URL Kustom</string>
|
||||
<string name="prefs_data_clear">Bersihkan</string>
|
||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||
<string name="prefs_data_sources_url_title">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||
<string name="prefs_data_output_title">Keluaran data</string>
|
||||
<string name="prefs_net_output">Jaringan</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||
<string name="rc_radio_model">Model Radio</string>
|
||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||
<string name="tracking_status">Melacak: %s</string>
|
||||
<string name="loc_lat_text">Lintang: %s°</string>
|
||||
<string name="loc_lon_text">Bujur: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">Hubungkan</string>
|
||||
<string name="disconnect_radio">Putuskan</string>
|
||||
<string name="track_radio">Lacak</string>
|
||||
<string name="stop_radio">Berhenti</string>
|
||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
||||
<string name="wavelog_title">Log</string>
|
||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
||||
<string name="wavelog_mode_hint">Mode</string>
|
||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
||||
<string name="wavelog_empty">Belum ada entri log</string>
|
||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
||||
<string name="wavelog_cancel">Batal</string>
|
||||
<string name="wavelog_copy">Salin</string>
|
||||
<string name="wavelog_delete_undo">Urungkan</string>
|
||||
<string name="wavelog_col_time">Waktu</string>
|
||||
<string name="wavelog_col_freq">Frek.</string>
|
||||
<string name="wavelog_col_sat">Satelit</string>
|
||||
<string name="wavelog_col_call">Panggilan</string>
|
||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
|
||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||
<string name="prefs_outro_thanks">
|
||||
* Pengguna dan kontributor Look4Sat!
|
||||
\n* David A. B. Johnson (predict4java)
|
||||
\n* Dave Moten (predict4java)
|
||||
\n* Alexandru Csete (Gpredict)
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl dan kontributor Robot36!
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
</resources>
|
||||
@@ -13,6 +13,8 @@
|
||||
<string name="nav_cw">CW Dekoder</string>
|
||||
<string name="nav_map">Harita</string>
|
||||
<string name="nav_prefs">Ayarlar</string>
|
||||
<string name="nav_more">Daha Fazla</string>
|
||||
<string name="nav_log">Günlük</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||
@@ -52,8 +54,8 @@
|
||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Transponder CW kod çözücü paneli artık geliştirilmiş kod çözme motorunu canlı şelale (waterfall) görüntüsüyle kullanıyor
|
||||
* CW kod çözme sayfaları aynı motoru paylaşıyor: gezinme sayfası ve transponder paneli</string>
|
||||
* WaveLog artık hem API v1 hem v2 destekliyor (otomatik algılama); kopyalama butonlu hata penceresi
|
||||
* Günlük frekansları Doppler paneliyle eşleşiyor; doğrusal transponderler tam aralığı gösteriyor</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
@@ -118,7 +120,7 @@
|
||||
|
||||
<string name="prefs_loc_title">İstasyon konumu</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
|
||||
<string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
|
||||
<string name="prefs_loc_input_title">Manuel giriş</string>
|
||||
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
@@ -138,6 +140,7 @@
|
||||
<string name="prefs_data_clear">Temizle</string>
|
||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||
@@ -178,9 +181,47 @@
|
||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||
|
||||
<string name="tracking_status">İzleniyor: %s</string>
|
||||
<string name="loc_lat_text">Enlem: %s°</string>
|
||||
<string name="loc_lon_text">Boylam: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">Bağlan</string>
|
||||
<string name="disconnect_radio">Bağlantıyı Kes</string>
|
||||
<string name="track_radio">Takip</string>
|
||||
<string name="stop_radio">Dur</string>
|
||||
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
|
||||
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
|
||||
<string name="prefs_wavelog_key_hint">API anahtarı</string>
|
||||
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
|
||||
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
|
||||
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
|
||||
<string name="prefs_wavelog_upload">Şimdi yükle</string>
|
||||
<string name="wavelog_title">Günlük</string>
|
||||
<string name="wavelog_select_transponder">Transponder seç</string>
|
||||
<string name="wavelog_call_hint">Çağrı işareti</string>
|
||||
<string name="wavelog_mode_hint">Mod</string>
|
||||
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
|
||||
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
|
||||
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
|
||||
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
|
||||
<string name="wavelog_ignore">Yoksay ve yükle</string>
|
||||
<string name="wavelog_cancel">İptal</string>
|
||||
<string name="wavelog_copy">Kopyala</string>
|
||||
<string name="wavelog_delete_undo">Geri al</string>
|
||||
<string name="wavelog_col_time">Zaman</string>
|
||||
<string name="wavelog_col_freq">Frekans</string>
|
||||
<string name="wavelog_col_sat">Uydu</string>
|
||||
<string name="wavelog_col_call">Çağrı</string>
|
||||
<string name="wavelog_col_uploaded">Gönderildi</string>
|
||||
<string name="wavelog_ungrouped">Gruplanmamış</string>
|
||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||
<string name="prefs_ui_title">UI Ayarları</string>
|
||||
<string name="prefs_ui_order_title">Sayfa sırası</string>
|
||||
<string name="prefs_ui_main_title">Ana menü (alt çubuk)</string>
|
||||
<string name="prefs_ui_sub_title">Daha fazla menü</string>
|
||||
<string name="prefs_ui_move_out">Daha fazla menüye taşı</string>
|
||||
<string name="prefs_ui_move_back">Ana menüye geri taşı</string>
|
||||
<string name="prefs_ui_order_locked">Sabit (taşınamaz)</string>
|
||||
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
|
||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="nav_cw">CW解码</string>
|
||||
<string name="nav_map">地图</string>
|
||||
<string name="nav_prefs">设置</string>
|
||||
<string name="nav_more">更多</string>
|
||||
<string name="nav_log">日志</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">类型:%s</string>
|
||||
@@ -42,8 +44,8 @@
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 转发器 CW 解码面板已接入增强解码引擎,带实时瀑布图显示
|
||||
* CW 解码入口共用同一引擎:导航页与转发器面板</string>
|
||||
* WaveLog 同时支持 API v1 和 v2(自动识别); 错误弹窗带一键复制
|
||||
* 日志频率与多普勒面板一致; 线性转发器显示完整频段区间</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -100,7 +102,7 @@
|
||||
|
||||
<string name="prefs_loc_title">站位</string>
|
||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
||||
<string name="prefs_loc_gps_error">无法获取位置,请检查定位权限和GPS/网络信号</string>
|
||||
<string name="prefs_loc_input_title">输入位置</string>
|
||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||
@@ -120,6 +122,7 @@
|
||||
<string name="prefs_data_clear">清空</string>
|
||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||
<string name="prefs_data_update_success">更新成功</string>
|
||||
<string name="prefs_data_update_failed">更新失败,请检查网络</string>
|
||||
|
||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
@@ -161,9 +164,47 @@
|
||||
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
|
||||
<string name="tracking_status">跟踪: %s</string>
|
||||
<string name="loc_lat_text">纬度: %s°</string>
|
||||
<string name="loc_lon_text">经度: %s°</string>
|
||||
<string name="loc_qth_text">QTH: %s</string>
|
||||
<string name="connect_radio">连接</string>
|
||||
<string name="disconnect_radio">断开</string>
|
||||
<string name="track_radio">跟踪</string>
|
||||
<string name="stop_radio">停止</string>
|
||||
<string name="prefs_wavelog_title">WaveLog 日志服务器</string>
|
||||
<string name="prefs_wavelog_url_hint">服务器地址</string>
|
||||
<string name="prefs_wavelog_key_hint">API 密钥</string>
|
||||
<string name="prefs_wavelog_station_hint">站点 ID</string>
|
||||
<string name="prefs_wavelog_auto">自动上传</string>
|
||||
<string name="prefs_wavelog_test">测试连接</string>
|
||||
<string name="prefs_wavelog_upload">手动上传</string>
|
||||
<string name="wavelog_title">日志</string>
|
||||
<string name="wavelog_select_transponder">选择转发器</string>
|
||||
<string name="wavelog_call_hint">对方呼号</string>
|
||||
<string name="wavelog_mode_hint">模式</string>
|
||||
<string name="wavelog_saved">已存入本地日志</string>
|
||||
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
|
||||
<string name="wavelog_empty">暂无日志记录</string>
|
||||
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
||||
<string name="wavelog_ignore">忽略并上传</string>
|
||||
<string name="wavelog_cancel">取消</string>
|
||||
<string name="wavelog_copy">复制</string>
|
||||
<string name="wavelog_delete_undo">撤销</string>
|
||||
<string name="wavelog_col_time">时间</string>
|
||||
<string name="wavelog_col_freq">频率</string>
|
||||
<string name="wavelog_col_sat">卫星</string>
|
||||
<string name="wavelog_col_call">呼号</string>
|
||||
<string name="wavelog_col_uploaded">已传</string>
|
||||
<string name="wavelog_ungrouped">未分组</string>
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_ui_title">UI设置</string>
|
||||
<string name="prefs_ui_order_title">页面顺序</string>
|
||||
<string name="prefs_ui_main_title">主菜单(底部栏)</string>
|
||||
<string name="prefs_ui_sub_title">更多菜单</string>
|
||||
<string name="prefs_ui_move_out">移入更多菜单</string>
|
||||
<string name="prefs_ui_move_back">移回主菜单</string>
|
||||
<string name="prefs_ui_order_locked">固定(不可移动)</string>
|
||||
<string name="prefs_ui_order_reset">重置顺序</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<string name="nav_cw">CW Decoder</string>
|
||||
<string name="nav_map">Map</string>
|
||||
<string name="nav_prefs">Settings</string>
|
||||
<string name="nav_more">More</string>
|
||||
<string name="nav_log">Log</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Type: %s</string>
|
||||
@@ -54,8 +56,8 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Transponder CW decoder panel now uses the enhanced decoding engine with a live waterfall display
|
||||
* CW decoding pages share the same engine: navigation page and transponder panel</string>
|
||||
* WaveLog now supports both API v1 and v2 (auto-detected); error dialog with copy button
|
||||
* Log frequencies match the Doppler panel; linear transponders show the full range</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -130,7 +132,7 @@
|
||||
|
||||
<string name="prefs_loc_title">Station position</string>
|
||||
<string name="prefs_loc_gps_title">GPS</string>
|
||||
<string name="prefs_loc_gps_error">Check your location permissions</string>
|
||||
<string name="prefs_loc_gps_error">Unable to get location - check permission and GPS/network signal</string>
|
||||
<string name="prefs_loc_input_title">Input</string>
|
||||
<string name="prefs_loc_input_error">Invalid location entered</string>
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
@@ -150,6 +152,7 @@
|
||||
<string name="prefs_data_clear">Clear</string>
|
||||
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
||||
<string name="prefs_data_update_success">Update completed successfully</string>
|
||||
<string name="prefs_data_update_failed">Update failed - check your network</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
|
||||
@@ -192,9 +195,47 @@
|
||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||
|
||||
<string name="tracking_status">Tracking: %s</string>
|
||||
<string name="loc_lat_text">Lat: %s°</string>
|
||||
<string name="loc_lon_text">Lon: %s°</string>
|
||||
<string name="loc_qth_text">Qth: %s</string>
|
||||
<string name="connect_radio">Connect</string>
|
||||
<string name="disconnect_radio">Disconnect</string>
|
||||
<string name="track_radio">Track</string>
|
||||
<string name="stop_radio">Stop</string>
|
||||
<string name="prefs_wavelog_title">WaveLog log server</string>
|
||||
<string name="prefs_wavelog_url_hint">Server address</string>
|
||||
<string name="prefs_wavelog_key_hint">API key</string>
|
||||
<string name="prefs_wavelog_station_hint">Station ID</string>
|
||||
<string name="prefs_wavelog_auto">Auto upload</string>
|
||||
<string name="prefs_wavelog_test">Test connection</string>
|
||||
<string name="prefs_wavelog_upload">Upload now</string>
|
||||
<string name="wavelog_title">Log</string>
|
||||
<string name="wavelog_select_transponder">Select transponder</string>
|
||||
<string name="wavelog_call_hint">Callsign</string>
|
||||
<string name="wavelog_mode_hint">Mode</string>
|
||||
<string name="wavelog_saved">Saved to local log</string>
|
||||
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
|
||||
<string name="wavelog_empty">No log entries yet</string>
|
||||
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
|
||||
<string name="wavelog_ignore">Ignore and upload</string>
|
||||
<string name="wavelog_cancel">Cancel</string>
|
||||
<string name="wavelog_copy">Copy</string>
|
||||
<string name="wavelog_delete_undo">Undo</string>
|
||||
<string name="wavelog_col_time">Time</string>
|
||||
<string name="wavelog_col_freq">Freq</string>
|
||||
<string name="wavelog_col_sat">Satellite</string>
|
||||
<string name="wavelog_col_call">Call</string>
|
||||
<string name="wavelog_col_uploaded">Sent</string>
|
||||
<string name="wavelog_ungrouped">Ungrouped</string>
|
||||
<string name="prefs_other_title">Other settings</string>
|
||||
<string name="prefs_ui_title">UI Settings</string>
|
||||
<string name="prefs_ui_order_title">Page order</string>
|
||||
<string name="prefs_ui_main_title">Main menu (bottom bar)</string>
|
||||
<string name="prefs_ui_sub_title">More menu</string>
|
||||
<string name="prefs_ui_move_out">Move to more menu</string>
|
||||
<string name="prefs_ui_move_back">Move back to main menu</string>
|
||||
<string name="prefs_ui_order_locked">Fixed (cannot be moved)</string>
|
||||
<string name="prefs_ui_order_reset">Reset order</string>
|
||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||
|
||||
@@ -181,7 +181,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Microphone permission is required for CW decoding",
|
||||
text = stringResource(R.string.cw_mic_permission),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
@@ -190,7 +190,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
Button(onClick = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}) {
|
||||
Text("Grant permission")
|
||||
Text(stringResource(R.string.cw_grant_permission))
|
||||
}
|
||||
if (permanentlyDenied) {
|
||||
// 勾选了"不再询问": 引导去系统设置开启
|
||||
@@ -202,7 +202,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
)
|
||||
)
|
||||
}) {
|
||||
Text("Open app settings")
|
||||
Text(stringResource(R.string.cw_open_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">Diam</string>
|
||||
<string name="waterfall_display">Tampilan waterfall</string>
|
||||
<string name="frequency_scale">Skala frekuensi</string>
|
||||
<string name="decoded_text">Teks terdekode</string>
|
||||
<string name="clear">Bersihkan</string>
|
||||
<string name="pause">Jeda Dekode</string>
|
||||
<string name="save_text">Simpan Teks</string>
|
||||
<string name="record_signals">Rekam Sinyal</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="help">Bantuan Daring</string>
|
||||
<string name="rate">Nilai Aplikasi Ini</string>
|
||||
<string name="premium">Dapatkan Versi Premium</string>
|
||||
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
||||
<string name="message_type">Tipe Pesan</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
||||
<string name="cw_grant_permission">Beri izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">Diam</string>
|
||||
<string name="waterfall_display">Tampilan waterfall</string>
|
||||
<string name="frequency_scale">Skala frekuensi</string>
|
||||
<string name="decoded_text">Teks terdekode</string>
|
||||
<string name="clear">Bersihkan</string>
|
||||
<string name="pause">Jeda Dekode</string>
|
||||
<string name="save_text">Simpan Teks</string>
|
||||
<string name="record_signals">Rekam Sinyal</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="help">Bantuan Daring</string>
|
||||
<string name="rate">Nilai Aplikasi Ini</string>
|
||||
<string name="premium">Dapatkan Versi Premium</string>
|
||||
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
||||
<string name="message_type">Tipe Pesan</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
||||
<string name="cw_grant_permission">Beri izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">Beklemede</string>
|
||||
<string name="waterfall_display">Şelale görüntüsü</string>
|
||||
<string name="frequency_scale">Frekans ölçeği</string>
|
||||
<string name="decoded_text">Çözülen metin</string>
|
||||
<string name="clear">Temizle</string>
|
||||
<string name="pause">Çözmeyi Duraklat</string>
|
||||
<string name="save_text">Metni Kaydet</string>
|
||||
<string name="record_signals">Sinyalleri Kaydet</string>
|
||||
<string name="settings">Ayarlar</string>
|
||||
<string name="help">Çevrimiçi Yardım</string>
|
||||
<string name="rate">Bu Uygulamayı Değerlendir</string>
|
||||
<string name="premium">Premium Sürümü Al</string>
|
||||
<string name="tap_back_again_to_close">Kapatmak için Geriye tekrar dokunun</string>
|
||||
<string name="message_type">Mesaj Türü</string>
|
||||
<string name="cw_mic_permission">CW çözme için mikrofon izni gereklidir</string>
|
||||
<string name="cw_grant_permission">İzin ver</string>
|
||||
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_500">#ff6200ee</color>
|
||||
<integer name="default_font_size">18</integer>
|
||||
<string name="idle">空闲</string>
|
||||
<string name="waterfall_display">瀑布图</string>
|
||||
<string name="frequency_scale">频率刻度</string>
|
||||
<string name="decoded_text">解码文本</string>
|
||||
<string name="clear">清除</string>
|
||||
<string name="pause">暂停解码</string>
|
||||
<string name="save_text">保存文本</string>
|
||||
<string name="record_signals">录音信号</string>
|
||||
<string name="settings">设置</string>
|
||||
<string name="help">在线帮助</string>
|
||||
<string name="rate">评价此应用</string>
|
||||
<string name="premium">获取高级版</string>
|
||||
<string name="tap_back_again_to_close">再次按返回键退出应用</string>
|
||||
<string name="message_type">消息类型</string>
|
||||
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
||||
<string name="cw_grant_permission">授予权限</string>
|
||||
<string name="cw_open_settings">打开应用设置</string>
|
||||
</resources>
|
||||
@@ -16,4 +16,7 @@
|
||||
<string name="premium">Get Premium Version</string>
|
||||
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
|
||||
<string name="message_type">Message Type</string>
|
||||
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
||||
<string name="cw_grant_permission">Grant permission</string>
|
||||
<string name="cw_open_settings">Open app settings</string>
|
||||
</resources>
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -57,6 +58,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
@@ -94,22 +96,29 @@ fun MutualScreen(
|
||||
)
|
||||
},
|
||||
bottomInfo = {
|
||||
if (state.mutualPasses.isNotEmpty()) {
|
||||
Text(
|
||||
text = "找到 ${state.mutualPasses.size} 个匹配",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = mutualStatusText(state),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
},
|
||||
endAction = {
|
||||
if (state.isCalculating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.width(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (state.isCalculating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.width(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
MutualStatusChip(state)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -187,8 +196,6 @@ private fun MutualContent(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
MatchIntroCard()
|
||||
|
||||
if (isVertical) {
|
||||
StationInputCard(
|
||||
title = "你的位置",
|
||||
@@ -304,25 +311,50 @@ private fun MutualContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchIntroCard(modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "位置匹配",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "输入双方位置,查找共同可见的卫星过境窗口",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
private fun MutualStatusChip(state: MutualUiState) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val containerColor = when {
|
||||
state.isCalculating -> colorScheme.primaryContainer
|
||||
state.errorMessage != null -> colorScheme.errorContainer
|
||||
state.mutualPasses.isNotEmpty() -> colorScheme.primary
|
||||
state.hasSearched -> colorScheme.surfaceVariant
|
||||
else -> colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor = when {
|
||||
state.isCalculating -> colorScheme.onPrimaryContainer
|
||||
state.errorMessage != null -> colorScheme.onErrorContainer
|
||||
state.mutualPasses.isNotEmpty() -> colorScheme.onPrimary
|
||||
else -> colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = mutualStatusChipText(state),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mutualStatusText(state: MutualUiState): String = when {
|
||||
state.isCalculating -> "正在计算双方共同可见窗口…"
|
||||
state.errorMessage != null -> "请修正提示后重新查询"
|
||||
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线"
|
||||
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试"
|
||||
else -> "输入双方位置后点击查询"
|
||||
}
|
||||
|
||||
private fun mutualStatusChipText(state: MutualUiState): String = when {
|
||||
state.isCalculating -> "计算中"
|
||||
state.errorMessage != null -> "有错误"
|
||||
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配"
|
||||
state.hasSearched -> "无匹配"
|
||||
else -> "待查询"
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+10
-1
@@ -49,6 +49,7 @@ data class MutualUiState(
|
||||
val hoursAhead: Int = 24,
|
||||
val mutualPasses: List<MutualPass> = emptyList(),
|
||||
val isCalculating: Boolean = false,
|
||||
val hasSearched: Boolean = false,
|
||||
val selectedPassIndex: Int = -1,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
@@ -167,7 +168,15 @@ class MutualViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCalculating = true,
|
||||
hasSearched = true,
|
||||
errorMessage = null,
|
||||
mutualPasses = emptyList(),
|
||||
selectedPassIndex = -1
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val time = System.currentTimeMillis()
|
||||
|
||||
+17
-3
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
@@ -162,7 +163,10 @@ class PassesViewModel(
|
||||
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
|
||||
}
|
||||
|
||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace.
|
||||
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
|
||||
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
|
||||
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
|
||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
@@ -171,7 +175,7 @@ class PassesViewModel(
|
||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||
// DeepSpace group always shows today's sun times
|
||||
if (passes.any { it.isDeepSpace }) {
|
||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
|
||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||
result["DeepSpace (period >225min)"] = rise to set
|
||||
@@ -180,7 +184,7 @@ class PassesViewModel(
|
||||
if (pass.isDeepSpace) continue
|
||||
val label = sdfDate.format(Date(pass.aosTime))
|
||||
if (label in result) continue
|
||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
|
||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||
result[label] = rise to set
|
||||
@@ -188,6 +192,16 @@ class PassesViewModel(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
|
||||
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
||||
timeInMillis = timeMillis
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
|
||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
val sdfDate = dateFormat(tz)
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/*
|
||||
* LogTab.kt — 雷达页「Log」标签(4.5.2 WaveLog 日志)。
|
||||
*
|
||||
* 布局: 转发器列表(点开选一个, 边看频率边填) → 呼号输入回车存本地 →
|
||||
* 下方本地记录列表(时间/频率/呼号)。记录支持自绘左滑删除:
|
||||
* 滑动中显示黄色垃圾桶, 滑过 75% 变撤销键, 5 秒内不点撤销则删除。
|
||||
*
|
||||
* 回车只存本地(WavelogQueue), 上传由 10 分钟周期/手动触发(防抄收错误)。
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val WaveLogYellow = Color(0xFFFFC107)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogTab(
|
||||
transceivers: List<SatRadio>,
|
||||
orbitalPos: OrbitalPos?,
|
||||
satelliteName: String,
|
||||
queue: WavelogQueue,
|
||||
wavelogConfigured: Boolean,
|
||||
showToast: (String) -> Unit,
|
||||
txBaseFrequencyHz: Long? = null,
|
||||
aosTimeMs: Long = 0L,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedUuid by remember { mutableStateOf<String?>(null) }
|
||||
var refreshTick by remember { mutableIntStateOf(0) }
|
||||
val entries = remember(refreshTick) { queue.all() }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (!wavelogConfigured) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.wavelog_need_config),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// 转发器选择(下拉选择框): 选中后底下出现输入区
|
||||
// 选中项按 uuid 从 transceivers 实时派生(多普勒频率每秒更新, 频率显示随之刷新)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val selectedRadio = transceivers.firstOrNull { it.uuid == selectedUuid }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = menuExpanded,
|
||||
onExpandedChange = { menuExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedRadio?.info ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(id = R.string.wavelog_select_transponder)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
) {
|
||||
transceivers.forEach { radio ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = radio.info,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selectedUuid = radio.uuid
|
||||
menuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选中转发器后: 输入区(实时频率 + 呼号 + 模式)
|
||||
selectedRadio?.let { radio ->
|
||||
ExpandedLogInput(
|
||||
radio = radio,
|
||||
orbitalPos = orbitalPos,
|
||||
satelliteName = satelliteName,
|
||||
queue = queue,
|
||||
showToast = showToast,
|
||||
txBaseFrequencyHz = txBaseFrequencyHz,
|
||||
aosTimeMs = aosTimeMs,
|
||||
onSaved = { refreshTick++ }
|
||||
)
|
||||
}
|
||||
|
||||
// 本地记录列表(时间/频率/呼号)
|
||||
Text(
|
||||
text = stringResource(id = R.string.wavelog_saved),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (entries.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.wavelog_empty),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
entries.forEach { entry ->
|
||||
SwipeDeleteRow(
|
||||
onDelete = {
|
||||
queue.remove(entry.id)
|
||||
refreshTick++
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = formatLocalTime(entry.timeUtcMs),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(64.dp)
|
||||
)
|
||||
Text(
|
||||
text = formatFrequency(entry.freqTxHz),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(76.dp)
|
||||
)
|
||||
Text(
|
||||
text = entry.call,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 转发器展开输入区: 实时频率 + 呼号(回车存本地) + 模式修正 */
|
||||
@Composable
|
||||
private fun ExpandedLogInput(
|
||||
radio: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
satelliteName: String,
|
||||
queue: WavelogQueue,
|
||||
showToast: (String) -> Unit,
|
||||
txBaseFrequencyHz: Long? = null,
|
||||
aosTimeMs: Long = 0L,
|
||||
onSaved: () -> Unit
|
||||
) {
|
||||
var callsign by remember { mutableStateOf("") }
|
||||
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||
|
||||
val savedMsg = stringResource(id = R.string.wavelog_saved)
|
||||
|
||||
fun submit() {
|
||||
val call = callsign.trim().uppercase()
|
||||
if (call.length < 3) return
|
||||
// 频率直接取转发器栏的数字(radio 每秒多普勒修正, 回车那一秒的值)
|
||||
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||
queue.add(
|
||||
WavelogQso(
|
||||
id = UUID.randomUUID().toString(),
|
||||
timeUtcMs = System.currentTimeMillis(),
|
||||
call = call,
|
||||
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
||||
freqTxHz = tx,
|
||||
freqRxHz = rx,
|
||||
satName = satelliteName,
|
||||
sessionId = buildSessionId(satelliteName, aosTimeMs)
|
||||
)
|
||||
)
|
||||
callsign = ""
|
||||
onSaved()
|
||||
showToast(savedMsg)
|
||||
}
|
||||
|
||||
// 频率显示与转发器栏完全一致: TX 行 + RX 行(radio 来自每秒多普勒重算, 实时刷新)
|
||||
val txForDisplay = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||
val rxForDisplay = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||
val upLow = radio.uplinkLow
|
||||
val upHigh = radio.uplinkHigh
|
||||
val downLow = radio.downlinkLow
|
||||
val downHigh = radio.downlinkHigh
|
||||
val isLinearRange = upLow != null && upHigh != null && upLow != upHigh
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// TX 行
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "TX: ",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${formatFrequency(txForDisplay)} MHz",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (isLinearRange) {
|
||||
Text(
|
||||
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
// RX 行(与转发器栏 RX 一致)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "RX: ",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${formatFrequency(rxForDisplay)} MHz",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (downLow != null && downHigh != null && downLow != downHigh) {
|
||||
Text(
|
||||
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = callsign,
|
||||
onValueChange = { callsign = it.take(12) },
|
||||
label = { Text(stringResource(id = R.string.wavelog_call_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = mode,
|
||||
onValueChange = { mode = it.take(8) },
|
||||
label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自绘左滑删除行(用户定制交互):
|
||||
* 左滑 → 右侧黄色垃圾桶; 滑过 75% → 变撤销键(待删除, 不立即删);
|
||||
* 5 秒倒计时内点撤销恢复, 不点自动删除。
|
||||
*/
|
||||
@Composable
|
||||
internal fun SwipeDeleteRow(
|
||||
onDelete: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var pending by remember { mutableStateOf(false) }
|
||||
var countdown by remember { mutableIntStateOf(5) }
|
||||
var rowWidth by remember { mutableIntStateOf(0) }
|
||||
val threshold = rowWidth * 0.75f
|
||||
|
||||
// 平滑弹回/滑入(动画)
|
||||
val animatedOffset by animateFloatAsState(
|
||||
targetValue = offsetX,
|
||||
animationSpec = if (pending) tween(200) else spring(dampingRatio = Spring.DampingRatioNoBouncy),
|
||||
label = "swipeOffset"
|
||||
)
|
||||
|
||||
// 倒计时: 进入待删除后 5 秒自动删
|
||||
LaunchedEffect(pending) {
|
||||
if (pending) {
|
||||
countdown = 5
|
||||
while (countdown > 0) {
|
||||
delay(1000)
|
||||
countdown--
|
||||
}
|
||||
if (pending) {
|
||||
onDelete()
|
||||
pending = false
|
||||
offsetX = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { rowWidth = it.width }
|
||||
) {
|
||||
// 背景层(右侧图标: 垃圾桶 / 撤销+倒计时)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(WaveLogYellow.copy(alpha = 0.25f)),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
if (pending) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$countdown",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = WaveLogYellow
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.wavelog_delete_undo),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = WaveLogYellow,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(WaveLogYellow.copy(alpha = 0.15f))
|
||||
.clickable {
|
||||
pending = false
|
||||
offsetX = 0f
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 垃圾桶(滑动过程中一直显示, 黄色)
|
||||
Text(
|
||||
text = "🗑",
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
// 内容层(跟手滑动): 背景在 offset 内侧(随内容移动)!
|
||||
// 平时盖住垃圾桶, 左滑时内容带背景移开 → 右侧露出黄色垃圾桶/撤销倒计时
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { if (pending) { pending = false } },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
|
||||
},
|
||||
onDragEnd = {
|
||||
if (offsetX <= -threshold) {
|
||||
pending = true
|
||||
countdown = 5
|
||||
} else {
|
||||
offsetX = 0f
|
||||
}
|
||||
},
|
||||
onDragCancel = { offsetX = 0f }
|
||||
)
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatLocalTime(utcMs: Long): String {
|
||||
val cal = Calendar.getInstance(TimeZone.getDefault())
|
||||
cal.timeInMillis = utcMs
|
||||
return "%02d:%02d".format(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
|
||||
}
|
||||
|
||||
/** 场次 ID: 卫星名-AOS 时间戳(UTC yyyyMMdd-HHmm); 无 AOS 时用回车时刻 */
|
||||
private fun buildSessionId(satName: String, aosTimeMs: Long): String {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = if (aosTimeMs > 0) aosTimeMs else System.currentTimeMillis()
|
||||
val stamp = "%04d%02d%02d-%02d%02d".format(
|
||||
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||
)
|
||||
return "$satName-$stamp"
|
||||
}
|
||||
@@ -61,6 +61,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
@@ -78,6 +81,8 @@ import kotlin.math.PI
|
||||
|
||||
private enum class RadarPage(val title: String) {
|
||||
Transceivers("Transceivers"),
|
||||
Log("Log"),
|
||||
Calculator("Calculator"),
|
||||
Sstv("SSTV")
|
||||
}
|
||||
|
||||
@@ -119,9 +124,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
// WaveLog(4.5.2): 自动上传 — 每 10 分钟重试本地队列(开关开启时)
|
||||
val wavelogUploader = remember { container.provideWavelogUploader() }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(10 * 60 * 1000L)
|
||||
val s = container.settingsRepo.otherSettings.value
|
||||
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
|
||||
// 网格不一致时静默跳过(留给手动上传确认), 其余失败自动下次重试
|
||||
val outcome = wavelogUploader.uploadQueue()
|
||||
if (outcome is UploadOutcome.NeedConfirm) {
|
||||
// 跳过本次; 用户可在设置页手动上传并确认
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RadarScreen(
|
||||
uiState,
|
||||
viewModel::onAction,
|
||||
navigateUpAndClearMutual,
|
||||
mutualData,
|
||||
{ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) },
|
||||
wavelogQueue = container.wavelogQueue,
|
||||
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
|
||||
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
|
||||
},
|
||||
showToast = { msg -> container.provideShowToast()(msg) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -130,7 +159,10 @@ private fun RadarScreen(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
requestMicPermission: () -> Unit
|
||||
requestMicPermission: () -> Unit,
|
||||
wavelogQueue: WavelogQueue,
|
||||
wavelogConfigured: Boolean,
|
||||
showToast: (String) -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
val addToCalendar: () -> Unit = {
|
||||
@@ -184,11 +216,11 @@ private fun RadarScreen(
|
||||
}
|
||||
if (isVertical) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,18 +231,37 @@ private fun PagerCard(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit,
|
||||
wavelogQueue: WavelogQueue,
|
||||
wavelogConfigured: Boolean,
|
||||
showToast: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val pages = RadarPage.entries
|
||||
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
}
|
||||
val pages = remember(hasCalculatorPage) {
|
||||
buildList {
|
||||
add(RadarPage.Transceivers)
|
||||
add(RadarPage.Log)
|
||||
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||
add(RadarPage.Sstv)
|
||||
}
|
||||
}
|
||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(pages.size) {
|
||||
val lastPage = pages.lastIndex
|
||||
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
|
||||
}
|
||||
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||
pages.forEachIndexed { index, page ->
|
||||
Tab(
|
||||
selected = pagerState.currentPage == index,
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
)
|
||||
@@ -222,20 +273,35 @@ private fun PagerCard(
|
||||
) { pageIndex ->
|
||||
when (pages[pageIndex]) {
|
||||
RadarPage.Transceivers -> TransceiversPage(
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
cw = uiState.cw,
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
RadarPage.Calculator -> CalculatorPage(
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
cw = uiState.cw,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
RadarPage.Log -> LogTab(
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
satelliteName = uiState.currentPass?.name ?: "",
|
||||
queue = wavelogQueue,
|
||||
wavelogConfigured = wavelogConfigured,
|
||||
showToast = showToast,
|
||||
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
|
||||
aosTimeMs = uiState.currentPass?.aosTime ?: 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
-47
@@ -43,7 +43,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -90,11 +89,8 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
fun TransceiversPage(
|
||||
transceivers: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (transceivers.isEmpty()) {
|
||||
@@ -117,11 +113,8 @@ fun TransceiversPage(
|
||||
TransceiverItem(
|
||||
radio = radio,
|
||||
isExpanded = isExpanded,
|
||||
orbitalPos = orbitalPos,
|
||||
cw = cw,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission,
|
||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||
)
|
||||
}
|
||||
@@ -129,6 +122,87 @@ fun TransceiversPage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalculatorPage(
|
||||
transceivers: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val calculatorTransceivers = remember(transceivers) {
|
||||
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
}
|
||||
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
||||
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
||||
?: calculatorTransceivers.firstOrNull()
|
||||
}
|
||||
|
||||
if (selectedTransceiver == null) {
|
||||
EmptyTransceiversContent(modifier)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (calculatorTransceivers.size > 1) {
|
||||
item {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
calculatorTransceivers.forEach { radio ->
|
||||
FilterChip(
|
||||
selected = radio.uuid == selectedTransceiver.uuid,
|
||||
onClick = {
|
||||
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = transceiverTitle(radio),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
DopplerFrequencyCalculator(
|
||||
transponder = selectedTransceiver,
|
||||
orbitalPos = orbitalPos,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
CwDecoderPanel(
|
||||
cw = cw,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||
@@ -156,11 +230,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||
private fun TransceiverItem(
|
||||
radio: SatRadio,
|
||||
isExpanded: Boolean,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
@@ -201,11 +272,8 @@ private fun TransceiverItem(
|
||||
)
|
||||
}
|
||||
// Title with mode
|
||||
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||
val fullTitle = "$title ($mode)"
|
||||
Text(
|
||||
text = fullTitle,
|
||||
text = transceiverTitle(radio),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -253,11 +321,8 @@ private fun TransceiverItem(
|
||||
) {
|
||||
ExpandedRadioControl(
|
||||
radio = radio,
|
||||
orbitalPos = orbitalPos,
|
||||
cw = cw,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,11 +391,8 @@ private fun UnifiedFrequencyRow(
|
||||
@Composable
|
||||
private fun ExpandedRadioControl(
|
||||
radio: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -439,23 +501,6 @@ private fun ExpandedRadioControl(
|
||||
}
|
||||
}
|
||||
|
||||
// Doppler frequency calculator (linear transponders only)
|
||||
DopplerFrequencyCalculator(
|
||||
transponder = radio,
|
||||
orbitalPos = orbitalPos,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// CW decoder panel (linear transponders only)
|
||||
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
|
||||
CwDecoderPanel(
|
||||
cw = cw,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||
@@ -573,7 +618,7 @@ private fun DopplerFrequencyCalculator(
|
||||
},
|
||||
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -669,8 +714,8 @@ private fun CwDecoderPanel(
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = cw.isExpanded) {
|
||||
// Morse Expert 引擎(4.5.0 移植): 小布局 cw_panel_main(瀑布图 + 解码文本)
|
||||
// 自带 Kotlin 贝叶斯引擎(CwDecoder)不再用于此面板(代码保留作回退)
|
||||
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
|
||||
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
|
||||
val context = LocalContext.current
|
||||
val activity = remember { context as? Activity }
|
||||
val controller = remember { MainActivity() }
|
||||
@@ -680,7 +725,6 @@ private fun CwDecoderPanel(
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
var listening by remember { mutableStateOf(false) }
|
||||
|
||||
// 生命周期: 展开即绑定视图(不改宿主窗口); 收起/离开自动释放
|
||||
DisposableEffect(Unit) {
|
||||
if (activity != null) {
|
||||
controller.onCreate(activity, rootView, false)
|
||||
@@ -690,18 +734,17 @@ private fun CwDecoderPanel(
|
||||
controller.onDestroy()
|
||||
}
|
||||
}
|
||||
// 权限已授予(首次进入/授权回调统一入口): 初始化引擎 + 启动录音
|
||||
|
||||
LaunchedEffect(cw.hasPermission) {
|
||||
if (cw.hasPermission && !initialized) {
|
||||
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
|
||||
controller.onResume() // 立即启动录音
|
||||
controller.onPermissionGranted()
|
||||
controller.onResume()
|
||||
initialized = true
|
||||
listening = true
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Control buttons row(映射到控制器)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -744,7 +787,6 @@ private fun CwDecoderPanel(
|
||||
}
|
||||
}
|
||||
|
||||
// 瀑布图 + 解码文本(原版布局迷你版, 适配容器)
|
||||
AndroidView(
|
||||
factory = { rootView },
|
||||
modifier = Modifier
|
||||
@@ -771,5 +813,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun transceiverTitle(radio: SatRadio): String {
|
||||
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||
return "$title ($mode)"
|
||||
}
|
||||
|
||||
private val FREQ_ADJUSTMENTS =
|
||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* WavelogLogScreen.kt — 「日志」页面(更多菜单, 4.5.2 修复)。
|
||||
*
|
||||
* 表格形式展示本地存储的日志: 时间 | 频率 | 卫星 | 呼号 | 已上传(✓)。
|
||||
* 完整格子线(表头横线 + 行横线 + 列竖线 + 外边框); 行复用左滑删除。
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
private val CheckGreen = Color(0xFF4CAF50)
|
||||
private val GridLineColor = Color(0xFF3A3A3A)
|
||||
|
||||
@Composable
|
||||
fun WavelogLogScreen(
|
||||
queue: WavelogQueue,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var refreshTick by remember { mutableIntStateOf(0) }
|
||||
val entries = remember(refreshTick) { queue.all() }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(8.dp)
|
||||
) {
|
||||
// 表格外边框(左右 + 上下由表头/行底线构成)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
// 表头
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
) {
|
||||
HeaderCell(stringResource(id = R.string.wavelog_col_time), 72.dp)
|
||||
GridVLine()
|
||||
HeaderCell(stringResource(id = R.string.wavelog_col_freq), 64.dp)
|
||||
GridVLine()
|
||||
HeaderCell(stringResource(id = R.string.wavelog_col_sat), 0.dp, weight = 1.2f)
|
||||
GridVLine()
|
||||
HeaderCell(stringResource(id = R.string.wavelog_col_call), 0.dp, weight = 1f)
|
||||
GridVLine()
|
||||
HeaderCell(stringResource(id = R.string.wavelog_col_uploaded), 44.dp)
|
||||
}
|
||||
// 表头横线
|
||||
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
|
||||
// 表头与数据区留白
|
||||
Box(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.wavelog_empty),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
// 按场次分组(sessionId = 卫星名-AOS 时间戳); 空 sessionId 归"未分组"放最后
|
||||
val groups = entries.groupBy { it.sessionId.ifBlank { UNGROUPED } }
|
||||
.toSortedMap(compareByDescending<String> { id ->
|
||||
entries.filter { it.sessionId.ifBlank { UNGROUPED } == id }.minOfOrNull { it.timeUtcMs } ?: 0L
|
||||
}.thenByDescending { it })
|
||||
groups.forEach { (sessionId, groupEntries) ->
|
||||
// 组标题(场次名 + 本地时间)
|
||||
val (satPart, timePart) = parseSession(sessionId)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (sessionId == UNGROUPED) stringResource(id = R.string.wavelog_ungrouped)
|
||||
else "$satPart - ${formatSessionTime(timePart)}",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
// 组间横线(md --- 效果)
|
||||
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
|
||||
groupEntries.forEach { entry ->
|
||||
SwipeDeleteRow(
|
||||
onDelete = {
|
||||
queue.remove(entry.id)
|
||||
refreshTick++
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
) {
|
||||
Cell(formatLocalTime(entry.timeUtcMs), 72.dp)
|
||||
GridVLine()
|
||||
Cell(formatFrequency(entry.freqTxHz), 64.dp)
|
||||
GridVLine()
|
||||
Cell(entry.satName, 0.dp, weight = 1.2f)
|
||||
GridVLine()
|
||||
Cell(entry.call, 0.dp, weight = 1f)
|
||||
GridVLine()
|
||||
Box(modifier = Modifier.width(44.dp)) {
|
||||
if (entry.uploaded) {
|
||||
Text(
|
||||
text = "✓",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CheckGreen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 行分隔线
|
||||
HorizontalDivider(thickness = 1.dp, color = GridLineColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 列竖线 */
|
||||
@Composable
|
||||
private fun GridVLine() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.fillMaxHeight()
|
||||
.background(GridLineColor)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.HeaderCell(text: String, width: Dp, weight: Float = 0f) {
|
||||
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
modifier = mod.padding(horizontal = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.Cell(text: String, width: Dp, weight: Float = 0f) {
|
||||
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
modifier = mod.padding(horizontal = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
private const val UNGROUPED = "__ungrouped__"
|
||||
|
||||
/** 解析场次 ID: "ASRTU-1-20260804-2014" → (卫星名, "20260804-2014") */
|
||||
private fun parseSession(sessionId: String): Pair<String, String> {
|
||||
val parts = sessionId.split('-')
|
||||
if (parts.size < 3) return sessionId to ""
|
||||
val timePart = parts.takeLast(2).joinToString("-")
|
||||
val satPart = parts.dropLast(2).joinToString("-")
|
||||
return satPart to timePart
|
||||
}
|
||||
|
||||
/** 场次时间(UTC "20260804-2014") → 本地 "MM-dd HH:mm" */
|
||||
private fun formatSessionTime(timePart: String): String {
|
||||
if (timePart.length != 13 || timePart[8] != '-') return timePart
|
||||
return try {
|
||||
val year = timePart.substring(0, 4).toInt()
|
||||
val month = timePart.substring(4, 6).toInt()
|
||||
val day = timePart.substring(6, 8).toInt()
|
||||
val hour = timePart.substring(9, 11).toInt()
|
||||
val minute = timePart.substring(11, 13).toInt()
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.set(year, month - 1, day, hour, minute, 0)
|
||||
cal.timeInMillis = cal.timeInMillis + TimeZone.getDefault().getOffset(cal.timeInMillis)
|
||||
"%02d-%02d %02d:%02d".format(
|
||||
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
timePart
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatLocalTime(utcMs: Long): String {
|
||||
val cal = Calendar.getInstance(TimeZone.getDefault())
|
||||
cal.timeInMillis = utcMs
|
||||
return "%02d-%02d %02d:%02d".format(
|
||||
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||
)
|
||||
}
|
||||
+269
-58
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -43,16 +44,21 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -79,6 +85,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -96,6 +103,8 @@ import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.defaultScreenOrder
|
||||
import com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -108,6 +117,63 @@ fun SettingsDestination() {
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SettingsScreen(uiState, viewModel::onAction)
|
||||
|
||||
// WaveLog 网格不一致确认弹窗(4.5.2)
|
||||
val gridConfirm = viewModel.gridConfirm
|
||||
if (gridConfirm != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.resolveGridConfirm(false) },
|
||||
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.wavelog_grid_mismatch,
|
||||
gridConfirm.userGrid.take(4),
|
||||
gridConfirm.stationGrid.take(4)
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.resolveGridConfirm(true) }) {
|
||||
Text(text = stringResource(id = R.string.wavelog_ignore))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.resolveGridConfirm(false) }) {
|
||||
Text(text = stringResource(id = R.string.wavelog_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// WaveLog 错误详情弹窗(可一键复制)
|
||||
val wavelogError = viewModel.wavelogError
|
||||
if (wavelogError != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.wavelogError = null },
|
||||
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = wavelogError,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("WaveLog error", wavelogError))
|
||||
viewModel.wavelogError = null
|
||||
}) {
|
||||
Text(text = stringResource(id = R.string.wavelog_copy))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.wavelogError = null }) {
|
||||
Text(text = stringResource(id = R.string.btn_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -323,13 +389,16 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
)
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item { WavelogCard(uiState.otherSettings, onAction) }
|
||||
item {
|
||||
UiSettingsCard(
|
||||
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
||||
screenOrder = uiState.otherSettings.screenOrder,
|
||||
subMenuOrder = uiState.otherSettings.subMenuOrder,
|
||||
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
|
||||
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
|
||||
onResetOrder = { onAction(SettingsAction.ResetScreenOrder) }
|
||||
onResetOrder = { onAction(SettingsAction.ResetMenuOrder) },
|
||||
onUpdateMenu = { main, sub -> onAction(SettingsAction.UpdateMenuOrder(main, sub)) }
|
||||
)
|
||||
}
|
||||
item { CardCredits() }
|
||||
@@ -370,11 +439,11 @@ private fun LocationCard(
|
||||
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
||||
Text(text = stringResource(R.string.loc_lat_text, settings.stationPos.latitude))
|
||||
Text(text = stringResource(R.string.loc_lon_text, settings.stationPos.longitude))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
||||
Text(text = stringResource(R.string.loc_qth_text, settings.stationPos.qthLocator))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
@@ -553,13 +622,80 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
* 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢;
|
||||
* "设置"页固定显示(防止失去设置入口)。
|
||||
*/
|
||||
/** WaveLog 日志服务器设置卡片(4.5.2): 照用户截图布局(深色卡片/标签左输入右/按钮行) */
|
||||
@Composable
|
||||
private fun WavelogCard(
|
||||
settings: OtherSettings,
|
||||
onAction: (SettingsAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_wavelog_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = settings.wavelogUrl,
|
||||
onValueChange = {
|
||||
onAction(SettingsAction.UpdateWavelogSettings(it, settings.wavelogApiKey, settings.wavelogStationId, settings.wavelogAutoUpload))
|
||||
},
|
||||
label = { Text(stringResource(id = R.string.prefs_wavelog_url_hint)) },
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = settings.wavelogApiKey,
|
||||
onValueChange = {
|
||||
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, it, settings.wavelogStationId, settings.wavelogAutoUpload))
|
||||
},
|
||||
label = { Text(stringResource(id = R.string.prefs_wavelog_key_hint)) },
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = settings.wavelogStationId,
|
||||
onValueChange = {
|
||||
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, it, settings.wavelogAutoUpload))
|
||||
},
|
||||
label = { Text(stringResource(id = R.string.prefs_wavelog_station_hint)) },
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
SwitchRow(R.string.prefs_wavelog_auto, settings.wavelogAutoUpload) {
|
||||
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, settings.wavelogStationId, it))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CardButton(
|
||||
onClick = { onAction(SettingsAction.TestWavelogConnection) },
|
||||
text = stringResource(id = R.string.prefs_wavelog_test),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(
|
||||
onClick = { onAction(SettingsAction.UploadWavelogQueue) },
|
||||
text = stringResource(id = R.string.prefs_wavelog_upload),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UiSettingsCard(
|
||||
hiddenScreens: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>,
|
||||
onToggle: (String) -> Unit,
|
||||
onReorder: (List<String>) -> Unit,
|
||||
onResetOrder: () -> Unit
|
||||
onResetOrder: () -> Unit,
|
||||
onUpdateMenu: (List<String>, List<String>) -> Unit
|
||||
) {
|
||||
val screens = listOf(
|
||||
R.string.nav_sat to "Satellites",
|
||||
@@ -568,9 +704,29 @@ private fun UiSettingsCard(
|
||||
R.string.nav_mutual to "Mutual",
|
||||
R.string.nav_roaming to "Roaming",
|
||||
R.string.nav_cw to "CwDecode",
|
||||
R.string.nav_log to "WavelogLog",
|
||||
R.string.nav_map to "Map",
|
||||
R.string.nav_prefs to "Settings"
|
||||
) // name 必须与 Screen.screenId 一致 (R8 安全)
|
||||
// 主菜单项: 排除更多菜单项, Settings 固定最后
|
||||
val mainItems = remember(screens, screenOrder, subMenuOrder) {
|
||||
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
|
||||
screens.map { it.second }
|
||||
.filter { it !in sub }
|
||||
.sortedBy { name ->
|
||||
screenOrder.indexOf(name).let { idx ->
|
||||
if (idx != -1) idx else defaultScreenOrder.indexOf(name).let {
|
||||
if (it != -1) it else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
.sortedWith(compareBy { if (it == "Settings") 1 else 0 })
|
||||
}
|
||||
// 更多菜单项
|
||||
val subItems = remember(screens, subMenuOrder) {
|
||||
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
|
||||
sub.filter { s -> screens.any { (_, name) -> name == s } }
|
||||
}
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
@@ -594,7 +750,53 @@ private fun UiSettingsCard(
|
||||
text = stringResource(id = R.string.prefs_ui_order_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
DragOrderList(screens = screens, screenOrder = screenOrder, onReorder = onReorder)
|
||||
// 主菜单区(底部栏, 最多 5 项, 设置锁定最后)
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_ui_main_title),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
DragOrderList(
|
||||
items = mainItems,
|
||||
labels = screens,
|
||||
locked = setOf("Settings"),
|
||||
moveLabel = stringResource(id = R.string.prefs_ui_move_out),
|
||||
moveEnabled = { name -> name != "Settings" },
|
||||
onMove = { name ->
|
||||
onUpdateMenu(
|
||||
mainItems.filter { it != name },
|
||||
(subMenuOrder.ifEmpty { defaultSubMenuOrder } + name).distinct()
|
||||
)
|
||||
},
|
||||
onReorder = { main -> onUpdateMenu(main, subMenuOrder) }
|
||||
)
|
||||
// 更多菜单区(进「更多」的页面)
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_ui_sub_title),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
DragOrderList(
|
||||
items = subItems,
|
||||
labels = screens,
|
||||
moveLabel = stringResource(id = R.string.prefs_ui_move_back),
|
||||
// 按钮常显; 主菜单满 5 时自动让位(最后一个非"设置"项移入更多, 交换)
|
||||
moveEnabled = { true },
|
||||
onMove = { name ->
|
||||
val mainWithoutSettings = mainItems.filter { it != "Settings" }
|
||||
val needSwap = mainWithoutSettings.size >= 5
|
||||
val evicted = if (needSwap) mainWithoutSettings.last() else null
|
||||
val newMain = if (needSwap) {
|
||||
(mainWithoutSettings.dropLast(1) + name).distinct()
|
||||
} else {
|
||||
(mainWithoutSettings + name).distinct()
|
||||
}
|
||||
val newSub = (subItems.filter { it != name } +
|
||||
if (evicted != null) listOf(evicted) else emptyList()).distinct()
|
||||
onUpdateMenu(newMain, newSub)
|
||||
},
|
||||
onReorder = { sub -> onUpdateMenu(screenOrder, sub) }
|
||||
)
|
||||
TextButton(onClick = onResetOrder) {
|
||||
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
|
||||
}
|
||||
@@ -605,25 +807,18 @@ private fun UiSettingsCard(
|
||||
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
|
||||
@Composable
|
||||
private fun DragOrderList(
|
||||
screens: List<Pair<Int, String>>,
|
||||
screenOrder: List<String>,
|
||||
items: List<String>,
|
||||
labels: List<Pair<Int, String>>,
|
||||
locked: Set<String> = emptySet(),
|
||||
moveLabel: String? = null,
|
||||
moveEnabled: (String) -> Boolean = { true },
|
||||
onMove: ((String) -> Unit)? = null,
|
||||
onReorder: (List<String>) -> Unit
|
||||
) {
|
||||
val itemHeight = 48.dp
|
||||
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() }
|
||||
// 按 screenOrder 排序(空 = 默认顺序); remember 以 screens 参数为 key, 持久化后重组
|
||||
val ordered = remember(screens, screenOrder) {
|
||||
screens.sortedBy { (_, name) ->
|
||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置, 再兜底最后
|
||||
screenOrder.indexOf(name).let {
|
||||
if (it != -1) it else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(name).let {
|
||||
if (it != -1) it else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val items = remember(ordered) { mutableStateListOf<Pair<Int, String>>().apply { addAll(ordered) } }
|
||||
val localItems = remember(items) { mutableStateListOf<String>().apply { addAll(items) } }
|
||||
var draggingIndex by remember { mutableIntStateOf(-1) }
|
||||
var dragOffset by remember { mutableStateOf(0f) }
|
||||
|
||||
@@ -632,10 +827,11 @@ private fun DragOrderList(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight * items.size)
|
||||
.height(itemHeight * localItems.size)
|
||||
) {
|
||||
itemsIndexed(items, key = { _, s -> s.second }) { index, screen ->
|
||||
itemsIndexed(localItems, key = { _, name -> name }) { index, name ->
|
||||
val isDragging = draggingIndex == index
|
||||
val isLocked = name in locked
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -646,46 +842,61 @@ private fun DragOrderList(
|
||||
.animateItem()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = screen.first),
|
||||
text = stringResource(id = labels.first { it.second == name }.first),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.padding(20.dp)
|
||||
.pointerInput(screen.second) {
|
||||
detectDragGestures(
|
||||
onDragStart = { draggingIndex = items.indexOf(screen) },
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount.y
|
||||
val currentIndex = draggingIndex
|
||||
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
|
||||
.coerceIn(0, items.size - 1)
|
||||
if (target != currentIndex) {
|
||||
items.add(target, items.removeAt(currentIndex))
|
||||
dragOffset += (currentIndex - target) * itemHeightPx
|
||||
draggingIndex = target
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
onReorder(items.map { it.second })
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
// 小圆点手柄(触控区 padding 20dp 居中 -> 8dp 圆点)
|
||||
if (moveLabel != null && onMove != null && moveEnabled(name)) {
|
||||
TextButton(onClick = { onMove(name) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
|
||||
Text(moveLabel, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
if (isLocked) {
|
||||
// 锁定行(设置页): 无抓手, 固定最后
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_ui_order_locked),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
|
||||
)
|
||||
.size(48.dp)
|
||||
.padding(20.dp)
|
||||
.pointerInput(name) {
|
||||
detectDragGestures(
|
||||
onDragStart = { draggingIndex = localItems.indexOf(name) },
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount.y
|
||||
val currentIndex = draggingIndex
|
||||
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
|
||||
.coerceIn(0, localItems.size - 1)
|
||||
if (target != currentIndex) {
|
||||
localItems.add(target, localItems.removeAt(currentIndex))
|
||||
dragOffset += (currentIndex - target) * itemHeightPx
|
||||
draggingIndex = target
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
onReorder(localItems.toList())
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
// 小圆点手柄(触控区 padding 20dp 居中 -> 8dp 圆点)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -70,6 +70,18 @@ sealed interface SettingsAction {
|
||||
data class ReorderScreens(val order: List<String>) : SettingsAction
|
||||
// UI 设置: 重置为默认顺序
|
||||
data object ResetScreenOrder : SettingsAction
|
||||
// UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单)
|
||||
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction
|
||||
// UI 设置: 重置主菜单 + 更多菜单顺序
|
||||
data object ResetMenuOrder : SettingsAction
|
||||
// WaveLog 日志(4.5.2): 更新服务器配置
|
||||
data class UpdateWavelogSettings(
|
||||
val url: String, val apiKey: String, val stationId: String, val autoUpload: Boolean
|
||||
) : SettingsAction
|
||||
// WaveLog: 测试连接
|
||||
data object TestWavelogConnection : SettingsAction
|
||||
// WaveLog: 手动上传队列
|
||||
data object UploadWavelogQueue : SettingsAction
|
||||
|
||||
// Remote control
|
||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||
|
||||
+95
-2
@@ -25,11 +25,18 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogResult
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
class SettingsViewModel(
|
||||
private val databaseRepo: IDatabaseRepo,
|
||||
@@ -128,10 +135,27 @@ class SettingsViewModel(
|
||||
}
|
||||
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
|
||||
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) }
|
||||
is SettingsAction.UpdateMenuOrder -> settingsRepo.updateOtherSettings {
|
||||
it.copy(screenOrder = action.mainOrder, subMenuOrder = action.subOrder)
|
||||
}
|
||||
SettingsAction.ResetMenuOrder -> settingsRepo.updateOtherSettings {
|
||||
it.copy(screenOrder = emptyList(), subMenuOrder = emptyList())
|
||||
}
|
||||
// Remote control & data sources
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||
// WaveLog 日志(4.5.2)
|
||||
is SettingsAction.UpdateWavelogSettings -> settingsRepo.updateOtherSettings {
|
||||
it.copy(
|
||||
wavelogUrl = action.url,
|
||||
wavelogApiKey = action.apiKey,
|
||||
wavelogStationId = action.stationId,
|
||||
wavelogAutoUpload = action.autoUpload
|
||||
)
|
||||
}
|
||||
SettingsAction.TestWavelogConnection -> testWavelogConnection()
|
||||
SettingsAction.UploadWavelogQueue -> uploadWavelogQueue()
|
||||
// System
|
||||
is SettingsAction.ShowToast -> showToast(action.message)
|
||||
}
|
||||
@@ -140,7 +164,16 @@ class SettingsViewModel(
|
||||
// region Position helpers — consolidated from 3 near-identical functions
|
||||
|
||||
private fun setGpsPosition() {
|
||||
updatePosition(R.string.prefs_loc_gps_error) { settingsRepo.setStationPosition() }
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(positionSettings = it.positionSettings.copy(isUpdating = true)) }
|
||||
val success = settingsRepo.setStationPosition()
|
||||
_uiState.update {
|
||||
it.copy(positionSettings = it.positionSettings.copy(
|
||||
isUpdating = false,
|
||||
messageResId = if (success) R.string.prefs_loc_success else R.string.prefs_loc_gps_error
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setGeoPosition(latitude: Double, longitude: Double) {
|
||||
@@ -188,6 +221,7 @@ class SettingsViewModel(
|
||||
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
|
||||
}
|
||||
println(exception)
|
||||
showToast(R.string.prefs_data_update_failed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +238,65 @@ class SettingsViewModel(
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
|
||||
// region WaveLog(4.5.2)
|
||||
|
||||
// 由 SettingsScreen 注入的共享 uploader(容器级单例)
|
||||
var pendingUploader: WavelogUploader? = null
|
||||
|
||||
// 网格不一致确认弹窗状态(UI 观察后弹 AlertDialog)
|
||||
var gridConfirm by mutableStateOf<GridConfirmData?>(null)
|
||||
|
||||
// WaveLog 错误详情弹窗(UI 观察后弹 AlertDialog, 可一键复制)
|
||||
var wavelogError by mutableStateOf<String?>(null)
|
||||
|
||||
data class GridConfirmData(val stationGrid: String, val userGrid: String)
|
||||
|
||||
private fun testWavelogConnection() {
|
||||
viewModelScope.launch {
|
||||
val s = settingsRepo.otherSettings.value
|
||||
val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey, s.wavelogStationId)
|
||||
when (result) {
|
||||
is WavelogResult.Success -> showToast("WaveLog: ${result.message}")
|
||||
is WavelogResult.Failure -> wavelogError = result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadWavelogQueue() {
|
||||
viewModelScope.launch {
|
||||
val result = pendingUploader?.uploadQueue()
|
||||
when (result) {
|
||||
is UploadOutcome.Done -> {
|
||||
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
|
||||
wavelogError = result.firstError
|
||||
} else {
|
||||
showToast("WaveLog: ${result.message}")
|
||||
}
|
||||
}
|
||||
is UploadOutcome.NeedConfirm -> {
|
||||
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
|
||||
}
|
||||
null -> showToast("WaveLog: 上传失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 网格确认结果: 忽略并上传 / 取消 */
|
||||
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
|
||||
val confirm = gridConfirm ?: return
|
||||
gridConfirm = null
|
||||
if (!ignoreAndUpload) return
|
||||
viewModelScope.launch {
|
||||
val result = pendingUploader?.uploadQueue(force = true)
|
||||
when (result) {
|
||||
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
|
||||
else -> showToast("WaveLog: 上传失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
companion object {
|
||||
@@ -214,7 +307,7 @@ class SettingsViewModel(
|
||||
databaseRepo = container.databaseRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
).apply { pendingUploader = container.provideWavelogUploader() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "450"
|
||||
appVersionCode = "452"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.5.0"
|
||||
appVersionName = "4.5.2"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user