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
|
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.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -53,6 +60,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
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.roaming.RoamingScreen
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
@@ -137,7 +148,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
// 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 ->
|
.sortedBy { screen ->
|
||||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
||||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
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 }
|
.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
|
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||||
val mutualViewModel: MutualViewModel = viewModel(
|
val mutualViewModel: MutualViewModel = viewModel(
|
||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
@@ -161,13 +185,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
) {
|
) {
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
navItems.forEach { screen ->
|
mainNavItems.forEach { screen ->
|
||||||
val isSelected = when (currentKey) {
|
val isSelected = when (currentKey) {
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||||
|
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
@@ -178,11 +203,26 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSelected) return@item
|
if (isSelected) return@item
|
||||||
|
moreExpanded = false
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
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(
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
@@ -193,10 +233,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Column {
|
Box {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
onBack = navigateBack,
|
onBack = navigateBack,
|
||||||
transitionSpec = { fadeTransition },
|
transitionSpec = { fadeTransition },
|
||||||
popTransitionSpec = { fadeTransition },
|
popTransitionSpec = { fadeTransition },
|
||||||
@@ -216,7 +257,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
container.setMutualPassData(MutualPassData())
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
// navigateToRadar()
|
// navigateToRadar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry<Screen.Radar> {
|
entry<Screen.Radar> {
|
||||||
@@ -242,6 +283,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
entry<Screen.CwDecode> {
|
entry<Screen.CwDecode> {
|
||||||
CwDecodeScreen()
|
CwDecodeScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.WavelogLog> {
|
||||||
|
WavelogLogScreen(queue = container.wavelogQueue)
|
||||||
|
}
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
@@ -280,7 +324,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
@@ -296,6 +340,27 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; 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.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
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.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
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)
|
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 {
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val rc = settingsRepo.rcSettings.value
|
val rc = settingsRepo.rcSettings.value
|
||||||
@@ -160,7 +173,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
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 {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -176,6 +196,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
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
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.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
|
// 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
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
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()
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
}
|
}
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
|
|||||||
+47
-13
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
|
private val context: android.content.Context,
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String
|
||||||
@@ -94,6 +95,11 @@ class SettingsRepo(
|
|||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
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 = ","
|
private val separatorComma = ","
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
@@ -181,23 +187,41 @@ class SettingsRepo(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(): Boolean {
|
/** 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
|
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||||
try {
|
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
||||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
val signal = android.os.CancellationSignal()
|
||||||
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||||
val location = locationManager.getLastKnownLocation(providerDef)
|
// 15 秒超时
|
||||||
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
val timeout = handler.postDelayed({
|
||||||
println("Requesting location for $provider provider")
|
signal.cancel()
|
||||||
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
if (cont.isActive) cont.resume(false) { }
|
||||||
} else {
|
}, 15_000L)
|
||||||
|
val listener = androidx.core.util.Consumer<Location> { location ->
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
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) {
|
} catch (exception: SecurityException) {
|
||||||
println("No permissions were given - $exception")
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
if (cont.isActive) cont.resume(false) { }
|
||||||
|
}
|
||||||
|
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean {
|
override fun setStationPosition(locator: String): Boolean {
|
||||||
@@ -361,6 +385,11 @@ class SettingsRepo(
|
|||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
||||||
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
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
|
new
|
||||||
}
|
}
|
||||||
@@ -378,8 +407,13 @@ class SettingsRepo(
|
|||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
||||||
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: 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())),
|
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
|
//endregion
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
|||||||
override fun invoke(message: String) {
|
override fun invoke(message: String) {
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun invoke(resId: Int) {
|
||||||
|
invoke(context.getString(resId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.coreDomainPlugin)
|
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,
|
val highElevation: Double = 45.0,
|
||||||
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
||||||
val hiddenScreens: List<String> = emptyList(),
|
val hiddenScreens: List<String> = emptyList(),
|
||||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/匹配/漫游/地图/设置)
|
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
|
||||||
val screenOrder: List<String> = emptyList()
|
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(
|
data class DataSourcesSettings(
|
||||||
|
|||||||
+3
@@ -42,6 +42,9 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
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(
|
data class MutualPassData(
|
||||||
|
|||||||
+2
-1
@@ -45,7 +45,8 @@ interface ISettingsRepo {
|
|||||||
//region # Station position settings
|
//region # Station position settings
|
||||||
val stationPosition: StateFlow<GeoPos>
|
val stationPosition: StateFlow<GeoPos>
|
||||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||||
fun setStationPosition(): Boolean
|
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
|
||||||
|
suspend fun setStationPosition(): Boolean
|
||||||
fun setStationPosition(locator: String): Boolean
|
fun setStationPosition(locator: String): Boolean
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
|||||||
|
|
||||||
interface IShowToast {
|
interface IShowToast {
|
||||||
operator fun invoke(message: String)
|
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.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
|
|||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
* with an offset applied to the downlink (in Hz).
|
* with an offset applied to the downlink (in Hz).
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* 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(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
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)
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +99,26 @@ object DopplerFrequencyCalculator {
|
|||||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
&& upLow != upHigh && downLow != downHigh
|
&& 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,
|
upHigh: Long = 145_500_000L,
|
||||||
downLow: Long = 435_000_000L,
|
downLow: Long = 435_000_000L,
|
||||||
downHigh: Long? = 435_500_000L,
|
downHigh: Long? = 435_500_000L,
|
||||||
inverted: Boolean = false
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
) = SatRadio(
|
) = SatRadio(
|
||||||
uuid = "linear", info = "Linear Transponder", isAlive = true,
|
uuid = "linear", info = info, isAlive = true,
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun fmTransponder() = SatRadio(
|
private fun fmTransponder() = SatRadio(
|
||||||
@@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
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
|
@Test
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
@@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertNull(result)
|
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
|
@Test
|
||||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
|
|||||||
+1
-2
@@ -518,6 +518,5 @@ fun formatFrequency(frequencyHz: Long): String {
|
|||||||
if (frequencyHz <= 0) return "---"
|
if (frequencyHz <= 0) return "---"
|
||||||
val mhz = frequencyHz / 1_000_000
|
val mhz = frequencyHz / 1_000_000
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
val hz = frequencyHz % 1_000
|
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
|
||||||
}
|
}
|
||||||
+8
-2
@@ -44,12 +44,18 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
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
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI 设置: 默认页面顺序(与 MainScreen navItems 一致)
|
// UI 设置: 默认主菜单页面顺序(底部栏 5 槽)
|
||||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "CwDecode", "Map", "Settings")
|
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
||||||
|
|
||||||
|
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
|
||||||
|
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object RadarDestination : NavKey
|
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_cw">CW Dekoder</string>
|
||||||
<string name="nav_map">Harita</string>
|
<string name="nav_map">Harita</string>
|
||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
<string name="nav_more">Daha Fazla</string>
|
||||||
|
<string name="nav_log">Günlük</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
<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>
|
\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_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<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
|
* WaveLog artık hem API v1 hem v2 destekliyor (otomatik algılama); kopyalama butonlu hata penceresi
|
||||||
* CW kod çözme sayfaları aynı motoru paylaşıyor: gezinme sayfası ve transponder paneli</string>
|
* Günlük frekansları Doppler paneliyle eşleşiyor; doğrusal transponderler tam aralığı gösteriyor</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Geri</string>
|
<string name="radar_back">Geri</string>
|
||||||
@@ -118,7 +120,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">İstasyon konumu</string>
|
<string name="prefs_loc_title">İstasyon konumu</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</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_title">Manuel giriş</string>
|
||||||
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</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">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</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_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_title">Özel veri kaynakları</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</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_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
<string name="prefs_net_perm_error">Ağ 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_other_title">Diğer ayarlar</string>
|
||||||
<string name="prefs_ui_title">UI Ayarları</string>
|
<string name="prefs_ui_title">UI Ayarları</string>
|
||||||
<string name="prefs_ui_order_title">Sayfa sırası</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_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_utc">Geçiş saatini UTC olarak göster</string>
|
||||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</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_cw">CW解码</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
<string name="nav_more">更多</string>
|
||||||
|
<string name="nav_log">日志</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">类型:%s</string>
|
<string name="sat_type_hint">类型:%s</string>
|
||||||
@@ -42,8 +44,8 @@
|
|||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* 转发器 CW 解码面板已接入增强解码引擎,带实时瀑布图显示
|
* WaveLog 同时支持 API v1 和 v2(自动识别); 错误弹窗带一键复制
|
||||||
* CW 解码入口共用同一引擎:导航页与转发器面板</string>
|
* 日志频率与多普勒面板一致; 线性转发器显示完整频段区间</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">后退</string>
|
<string name="radar_back">后退</string>
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">站位</string>
|
<string name="prefs_loc_title">站位</string>
|
||||||
<string name="prefs_loc_gps_title">GPS 定位</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_title">输入位置</string>
|
||||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
<string name="prefs_loc_input_error">输入的位置无效</string>
|
||||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||||
@@ -120,6 +122,7 @@
|
|||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_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_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</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_rx_name_hint">接收电台名称</string>
|
||||||
<string name="rc_enable_switch">启用 CAT 控制</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_other_title">其他设置</string>
|
||||||
<string name="prefs_ui_title">UI设置</string>
|
<string name="prefs_ui_title">UI设置</string>
|
||||||
<string name="prefs_ui_order_title">页面顺序</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_ui_order_reset">重置顺序</string>
|
||||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<string name="nav_cw">CW Decoder</string>
|
<string name="nav_cw">CW Decoder</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
<string name="nav_more">More</string>
|
||||||
|
<string name="nav_log">Log</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Type: %s</string>
|
<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>
|
\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_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Transponder CW decoder panel now uses the enhanced decoding engine with a live waterfall display
|
* WaveLog now supports both API v1 and v2 (auto-detected); error dialog with copy button
|
||||||
* CW decoding pages share the same engine: navigation page and transponder panel</string>
|
* Log frequencies match the Doppler panel; linear transponders show the full range</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
@@ -130,7 +132,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">Station position</string>
|
<string name="prefs_loc_title">Station position</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</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_title">Input</string>
|
||||||
<string name="prefs_loc_input_error">Invalid location entered</string>
|
<string name="prefs_loc_input_error">Invalid location entered</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</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">Clear</string>
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</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_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_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>
|
<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_bt_perm_error">Check your bluetooth permission</string>
|
||||||
<string name="prefs_net_perm_error">Check your network 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_other_title">Other settings</string>
|
||||||
<string name="prefs_ui_title">UI Settings</string>
|
<string name="prefs_ui_title">UI Settings</string>
|
||||||
<string name="prefs_ui_order_title">Page order</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_ui_order_reset">Reset order</string>
|
||||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
|||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = "Microphone permission is required for CW decoding",
|
text = stringResource(R.string.cw_mic_permission),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 24.dp)
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
@@ -190,7 +190,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
|||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
}) {
|
}) {
|
||||||
Text("Grant permission")
|
Text(stringResource(R.string.cw_grant_permission))
|
||||||
}
|
}
|
||||||
if (permanentlyDenied) {
|
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="premium">Get Premium Version</string>
|
||||||
<string name="tap_back_again_to_close">Tap Back again to close the app</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="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>
|
</resources>
|
||||||
@@ -44,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
@@ -94,15 +96,20 @@ fun MutualScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomInfo = {
|
bottomInfo = {
|
||||||
if (state.mutualPasses.isNotEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "找到 ${state.mutualPasses.size} 个匹配",
|
text = mutualStatusText(state),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
if (state.isCalculating) {
|
if (state.isCalculating) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -111,6 +118,8 @@ fun MutualScreen(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
MutualStatusChip(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,8 +196,6 @@ private fun MutualContent(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
MatchIntroCard()
|
|
||||||
|
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
StationInputCard(
|
StationInputCard(
|
||||||
title = "你的位置",
|
title = "你的位置",
|
||||||
@@ -304,25 +311,50 @@ private fun MutualContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MatchIntroCard(modifier: Modifier = Modifier) {
|
private fun MutualStatusChip(state: MutualUiState) {
|
||||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
Column(
|
val containerColor = when {
|
||||||
modifier = Modifier.padding(12.dp),
|
state.isCalculating -> colorScheme.primaryContainer
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
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(
|
||||||
text = "位置匹配",
|
text = mutualStatusChipText(state),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
maxLines = 1,
|
||||||
)
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
Text(
|
|
||||||
text = "输入双方位置,查找共同可见的卫星过境窗口",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
|
|||||||
+10
-1
@@ -49,6 +49,7 @@ data class MutualUiState(
|
|||||||
val hoursAhead: Int = 24,
|
val hoursAhead: Int = 24,
|
||||||
val mutualPasses: List<MutualPass> = emptyList(),
|
val mutualPasses: List<MutualPass> = emptyList(),
|
||||||
val isCalculating: Boolean = false,
|
val isCalculating: Boolean = false,
|
||||||
|
val hasSearched: Boolean = false,
|
||||||
val selectedPassIndex: Int = -1,
|
val selectedPassIndex: Int = -1,
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
)
|
)
|
||||||
@@ -167,7 +168,15 @@ class MutualViewModel(
|
|||||||
return
|
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 {
|
viewModelScope.launch {
|
||||||
val time = System.currentTimeMillis()
|
val time = System.currentTimeMillis()
|
||||||
|
|||||||
+17
-3
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
@@ -162,7 +163,10 @@ class PassesViewModel(
|
|||||||
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
|
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>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
@@ -171,7 +175,7 @@ class PassesViewModel(
|
|||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
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 rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result["DeepSpace (period >225min)"] = rise to set
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
@@ -180,7 +184,7 @@ class PassesViewModel(
|
|||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
val label = sdfDate.format(Date(pass.aosTime))
|
val label = sdfDate.format(Date(pass.aosTime))
|
||||||
if (label in result) continue
|
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 rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result[label] = rise to set
|
result[label] = rise to set
|
||||||
@@ -188,6 +192,16 @@ class PassesViewModel(
|
|||||||
return result
|
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>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = dateFormat(tz)
|
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.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
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.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
@@ -78,6 +81,8 @@ import kotlin.math.PI
|
|||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
|
Log("Log"),
|
||||||
|
Calculator("Calculator"),
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +124,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||||
}
|
}
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
// WaveLog(4.5.2): 自动上传 — 每 10 分钟重试本地队列(开关开启时)
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
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
|
@Composable
|
||||||
@@ -130,7 +159,10 @@ private fun RadarScreen(
|
|||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
mutualData: MutualPassData,
|
mutualData: MutualPassData,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit,
|
||||||
|
wavelogQueue: WavelogQueue,
|
||||||
|
wavelogConfigured: Boolean,
|
||||||
|
showToast: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
@@ -184,11 +216,11 @@ private fun RadarScreen(
|
|||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
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 {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
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,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
|
wavelogQueue: WavelogQueue,
|
||||||
|
wavelogConfigured: Boolean,
|
||||||
|
showToast: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
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 pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(pages.size) {
|
||||||
|
val lastPage = pages.lastIndex
|
||||||
|
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
|
||||||
|
}
|
||||||
|
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||||
|
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = selectedTabIndex == index,
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
)
|
)
|
||||||
@@ -222,11 +273,16 @@ private fun PagerCard(
|
|||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
when (pages[pageIndex]) {
|
when (pages[pageIndex]) {
|
||||||
RadarPage.Transceivers -> TransceiversPage(
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
|
transceivers = uiState.transceivers.transmitters,
|
||||||
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
|
radioControl = uiState.radioControl,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
RadarPage.Calculator -> CalculatorPage(
|
||||||
transceivers = uiState.transceivers.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
orbitalPos = uiState.orbitalPos,
|
orbitalPos = uiState.orbitalPos,
|
||||||
cw = uiState.cw,
|
cw = uiState.cw,
|
||||||
radioControl = uiState.radioControl,
|
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
@@ -236,6 +292,16 @@ private fun PagerCard(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
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.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -90,11 +89,8 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
fun TransceiversPage(
|
fun TransceiversPage(
|
||||||
transceivers: List<SatRadio>,
|
transceivers: List<SatRadio>,
|
||||||
selectedUuid: String?,
|
selectedUuid: String?,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (transceivers.isEmpty()) {
|
if (transceivers.isEmpty()) {
|
||||||
@@ -117,11 +113,8 @@ fun TransceiversPage(
|
|||||||
TransceiverItem(
|
TransceiverItem(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
cw = cw,
|
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission,
|
|
||||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
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
|
@Composable
|
||||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
@@ -156,11 +230,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
|||||||
private fun TransceiverItem(
|
private fun TransceiverItem(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit,
|
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
@@ -201,11 +272,8 @@ private fun TransceiverItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Title with mode
|
// 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(
|
||||||
text = fullTitle,
|
text = transceiverTitle(radio),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -253,11 +321,8 @@ private fun TransceiverItem(
|
|||||||
) {
|
) {
|
||||||
ExpandedRadioControl(
|
ExpandedRadioControl(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
cw = cw,
|
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction,
|
onAction = onAction
|
||||||
requestMicPermission = requestMicPermission
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,11 +391,8 @@ private fun UnifiedFrequencyRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ExpandedRadioControl(
|
private fun ExpandedRadioControl(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
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
|
// Control buttons
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
@@ -573,7 +618,7 @@ private fun DopplerFrequencyCalculator(
|
|||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -669,8 +714,8 @@ private fun CwDecoderPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(visible = cw.isExpanded) {
|
AnimatedVisibility(visible = cw.isExpanded) {
|
||||||
// Morse Expert 引擎(4.5.0 移植): 小布局 cw_panel_main(瀑布图 + 解码文本)
|
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
|
||||||
// 自带 Kotlin 贝叶斯引擎(CwDecoder)不再用于此面板(代码保留作回退)
|
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = remember { context as? Activity }
|
val activity = remember { context as? Activity }
|
||||||
val controller = remember { MainActivity() }
|
val controller = remember { MainActivity() }
|
||||||
@@ -680,7 +725,6 @@ private fun CwDecoderPanel(
|
|||||||
var initialized by remember { mutableStateOf(false) }
|
var initialized by remember { mutableStateOf(false) }
|
||||||
var listening by remember { mutableStateOf(false) }
|
var listening by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 生命周期: 展开即绑定视图(不改宿主窗口); 收起/离开自动释放
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
if (activity != null) {
|
if (activity != null) {
|
||||||
controller.onCreate(activity, rootView, false)
|
controller.onCreate(activity, rootView, false)
|
||||||
@@ -690,18 +734,17 @@ private fun CwDecoderPanel(
|
|||||||
controller.onDestroy()
|
controller.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 权限已授予(首次进入/授权回调统一入口): 初始化引擎 + 启动录音
|
|
||||||
LaunchedEffect(cw.hasPermission) {
|
LaunchedEffect(cw.hasPermission) {
|
||||||
if (cw.hasPermission && !initialized) {
|
if (cw.hasPermission && !initialized) {
|
||||||
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
|
controller.onPermissionGranted()
|
||||||
controller.onResume() // 立即启动录音
|
controller.onResume()
|
||||||
initialized = true
|
initialized = true
|
||||||
listening = true
|
listening = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// Control buttons row(映射到控制器)
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@@ -744,7 +787,6 @@ private fun CwDecoderPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 瀑布图 + 解码文本(原版布局迷你版, 适配容器)
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { rootView },
|
factory = { rootView },
|
||||||
modifier = Modifier
|
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 =
|
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")
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
+239
-28
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.layout.width
|
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.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
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.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
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 com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -108,6 +117,63 @@ fun SettingsDestination() {
|
|||||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction)
|
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
|
@Composable
|
||||||
@@ -323,13 +389,16 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item { OtherCard(uiState.otherSettings, onAction) }
|
||||||
|
item { WavelogCard(uiState.otherSettings, onAction) }
|
||||||
item {
|
item {
|
||||||
UiSettingsCard(
|
UiSettingsCard(
|
||||||
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
||||||
screenOrder = uiState.otherSettings.screenOrder,
|
screenOrder = uiState.otherSettings.screenOrder,
|
||||||
|
subMenuOrder = uiState.otherSettings.subMenuOrder,
|
||||||
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
|
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
|
||||||
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
|
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
|
||||||
onResetOrder = { onAction(SettingsAction.ResetScreenOrder) }
|
onResetOrder = { onAction(SettingsAction.ResetMenuOrder) },
|
||||||
|
onUpdateMenu = { main, sub -> onAction(SettingsAction.UpdateMenuOrder(main, sub)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { CardCredits() }
|
item { CardCredits() }
|
||||||
@@ -370,11 +439,11 @@ private fun LocationCard(
|
|||||||
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
Text(text = stringResource(R.string.loc_lat_text, settings.stationPos.latitude))
|
||||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
Text(text = stringResource(R.string.loc_lon_text, settings.stationPos.longitude))
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
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))
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
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
|
@Composable
|
||||||
private fun UiSettingsCard(
|
private fun UiSettingsCard(
|
||||||
hiddenScreens: List<String>,
|
hiddenScreens: List<String>,
|
||||||
screenOrder: List<String>,
|
screenOrder: List<String>,
|
||||||
|
subMenuOrder: List<String>,
|
||||||
onToggle: (String) -> Unit,
|
onToggle: (String) -> Unit,
|
||||||
onReorder: (List<String>) -> Unit,
|
onReorder: (List<String>) -> Unit,
|
||||||
onResetOrder: () -> Unit
|
onResetOrder: () -> Unit,
|
||||||
|
onUpdateMenu: (List<String>, List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val screens = listOf(
|
val screens = listOf(
|
||||||
R.string.nav_sat to "Satellites",
|
R.string.nav_sat to "Satellites",
|
||||||
@@ -568,9 +704,29 @@ private fun UiSettingsCard(
|
|||||||
R.string.nav_mutual to "Mutual",
|
R.string.nav_mutual to "Mutual",
|
||||||
R.string.nav_roaming to "Roaming",
|
R.string.nav_roaming to "Roaming",
|
||||||
R.string.nav_cw to "CwDecode",
|
R.string.nav_cw to "CwDecode",
|
||||||
|
R.string.nav_log to "WavelogLog",
|
||||||
R.string.nav_map to "Map",
|
R.string.nav_map to "Map",
|
||||||
R.string.nav_prefs to "Settings"
|
R.string.nav_prefs to "Settings"
|
||||||
) // name 必须与 Screen.screenId 一致 (R8 安全)
|
) // 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()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
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),
|
text = stringResource(id = R.string.prefs_ui_order_title),
|
||||||
color = MaterialTheme.colorScheme.primary
|
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) {
|
TextButton(onClick = onResetOrder) {
|
||||||
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
|
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
|
||||||
}
|
}
|
||||||
@@ -605,25 +807,18 @@ private fun UiSettingsCard(
|
|||||||
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
|
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DragOrderList(
|
private fun DragOrderList(
|
||||||
screens: List<Pair<Int, String>>,
|
items: List<String>,
|
||||||
screenOrder: 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
|
onReorder: (List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val itemHeight = 48.dp
|
val itemHeight = 48.dp
|
||||||
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() }
|
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 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 draggingIndex by remember { mutableIntStateOf(-1) }
|
||||||
var dragOffset by remember { mutableStateOf(0f) }
|
var dragOffset by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
@@ -632,10 +827,11 @@ private fun DragOrderList(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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 isDragging = draggingIndex == index
|
||||||
|
val isLocked = name in locked
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -646,30 +842,44 @@ private fun DragOrderList(
|
|||||||
.animateItem()
|
.animateItem()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = screen.first),
|
text = stringResource(id = labels.first { it.second == name }.first),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.padding(20.dp)
|
.padding(20.dp)
|
||||||
.pointerInput(screen.second) {
|
.pointerInput(name) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { draggingIndex = items.indexOf(screen) },
|
onDragStart = { draggingIndex = localItems.indexOf(name) },
|
||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
dragOffset += dragAmount.y
|
dragOffset += dragAmount.y
|
||||||
val currentIndex = draggingIndex
|
val currentIndex = draggingIndex
|
||||||
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
|
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
|
||||||
.coerceIn(0, items.size - 1)
|
.coerceIn(0, localItems.size - 1)
|
||||||
if (target != currentIndex) {
|
if (target != currentIndex) {
|
||||||
items.add(target, items.removeAt(currentIndex))
|
localItems.add(target, localItems.removeAt(currentIndex))
|
||||||
dragOffset += (currentIndex - target) * itemHeightPx
|
dragOffset += (currentIndex - target) * itemHeightPx
|
||||||
draggingIndex = target
|
draggingIndex = target
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
onReorder(items.map { it.second })
|
onReorder(localItems.toList())
|
||||||
draggingIndex = -1
|
draggingIndex = -1
|
||||||
dragOffset = 0f
|
dragOffset = 0f
|
||||||
},
|
},
|
||||||
@@ -690,6 +900,7 @@ private fun DragOrderList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+12
@@ -70,6 +70,18 @@ sealed interface SettingsAction {
|
|||||||
data class ReorderScreens(val order: List<String>) : SettingsAction
|
data class ReorderScreens(val order: List<String>) : SettingsAction
|
||||||
// UI 设置: 重置为默认顺序
|
// UI 设置: 重置为默认顺序
|
||||||
data object ResetScreenOrder : SettingsAction
|
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
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
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.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
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 com.rtbishop.look4sat.core.presentation.R
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
@@ -128,10 +135,27 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
|
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
|
||||||
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) }
|
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
|
// Remote control & data sources
|
||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(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
|
// System
|
||||||
is SettingsAction.ShowToast -> showToast(action.message)
|
is SettingsAction.ShowToast -> showToast(action.message)
|
||||||
}
|
}
|
||||||
@@ -140,7 +164,16 @@ class SettingsViewModel(
|
|||||||
// region Position helpers — consolidated from 3 near-identical functions
|
// region Position helpers — consolidated from 3 near-identical functions
|
||||||
|
|
||||||
private fun setGpsPosition() {
|
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) {
|
private fun setGeoPosition(latitude: Double, longitude: Double) {
|
||||||
@@ -188,6 +221,7 @@ class SettingsViewModel(
|
|||||||
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
|
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
|
||||||
}
|
}
|
||||||
println(exception)
|
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
|
// endregion
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -214,7 +307,7 @@ class SettingsViewModel(
|
|||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast()
|
||||||
)
|
).apply { pendingUploader = container.provideWavelogUploader() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "450"
|
appVersionCode = "452"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.5.0"
|
appVersionName = "4.5.2"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user