Compare commits

...
Author SHA1 Message Date
atsunatsu a08a28df39 fix: recalculate passes when station position changes
Use combine(selectedIds, stationPosition) in SatelliteRepo.initRepository
so that passes are recalculated on either satellite selection change or
station position change. StateFlow already emits distinct values.
2026-08-08 20:30:08 +08:00
atsunatsu 759a6de2c6 chore: replace workflow badge with release version badge 2026-08-08 15:58:11 +08:00
atsunatsu ba5a984c16 fix: broaden linear transponder matching and deduplicate same-range entries
- isNamedLinearTransponder: match ' Lin' prefix (AO-7 style) and
  add fallback branch (hasLinearName && hasLinearMode) for entries
  with 'Linear'/'Lin' but no 'transponder' word (AO-7, AO-73)
- add deduplicateTransponders: merge entries sharing the same
  uplink/downlink frequency range, preferring non-CW entry
  (AO-7 Mode A had separate SSB and CW entries; JO-97 had
  separate CW and SSB Transponder entries)
- apply dedup in CalculatorPage filter chain
- 6 new tests, 23 total, all passing
2026-08-08 15:42:33 +08:00
atsunatsu 083b8a2847 Merge pull request #5 from MCKero6423/feat/thanks-bg7nta
Add BG7NTA (CW decoding feature) to the thanks list
2026-08-08 12:44:05 +08:00
mckero e8ebfb1ef3 feat(credits): add BG7NTA to the thanks list
Add BG7NTA (CW decoding feature) to the in-app credits/thanks section
across all three locale files (en, zh, tr).
2026-08-08 03:52:24 +00:00
atsunatsu 12eed1d9a9 i18n: use string resources for mutual match page (zh/en) 2026-08-07 21:27:01 +08:00
atsunatsu b3859fbece i18n: translate '友台' to 'opposite' for clarity 2026-08-07 20:06:43 +08:00
atsunatsu f40a67e1b7 revert: remove fldigi CW decoder migration, keep Morse Expert 2026-08-07 20:01:53 +08:00
atsunatsu 16dce5df2a chore: remove .cxx build artifacts from git tracking 2026-08-07 19:59:03 +08:00
atsunatsu 80a58bed61 i18n: localize mutual match page to English 2026-08-07 19:53:36 +08:00
atsunatsu a8afc63bba fix: default CW decoder to ham radio QSO mode 2026-08-07 18:43:01 +08:00
14 changed files with 240 additions and 60 deletions

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ out/
# Gradle files # Gradle files
.gradle/ .gradle/
build/ build/
.cxx/
# Local configuration file (sdk path, etc) # Local configuration file (sdk path, etc)
local.properties local.properties
+1 -1
View File
@@ -1,6 +1,6 @@
# Look4Sat-BA7OPF # Look4Sat-BA7OPF
[![Look4Sat CI](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml) [![Release](https://img.shields.io/github/v/release/atsunatsu/Look4Sat)](https://github.com/atsunatsu/Look4Sat/releases)
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。 **BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
@@ -34,6 +34,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.TimeZone import java.util.TimeZone
@@ -63,19 +64,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds -> combine(
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } settingsRepo.selectedIds,
val settings = settingsRepo.passesSettings.value settingsRepo.stationPosition
calculatePasses( ) { selectedIds, _ -> selectedIds }
time = System.currentTimeMillis(), .collect { selectedIds ->
hoursAhead = settings.hoursAhead, _satellites.update { localStorage.getEntriesWithIds(selectedIds) }
minElevation = settings.minElevation, val settings = settingsRepo.passesSettings.value
aosStartMinute = settings.aosStartMinute, calculatePasses(
aosEndMinute = settings.aosEndMinute, time = System.currentTimeMillis(),
invertAosTimeWindow = settings.invertAosTimeWindow, hoursAhead = settings.hoursAhead,
modes = settings.selectedModes minElevation = settings.minElevation,
) aosStartMinute = settings.aosStartMinute,
} aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
} }
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos { override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -144,11 +144,30 @@ object DopplerFrequencyCalculator {
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ") .joinToString(separator = " ")
.lowercase(Locale.ENGLISH) .lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear") val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") || val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr") info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) } val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
} }
} }
@@ -9,6 +9,7 @@ import org.junit.Test
class DopplerFrequencyCalculatorTest { class DopplerFrequencyCalculatorTest {
private fun linearTransponder( private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L, upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L, upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L, downLow: Long = 435_000_000L,
@@ -18,7 +19,7 @@ class DopplerFrequencyCalculatorTest {
downlinkMode: String? = "USB", downlinkMode: String? = "USB",
uplinkMode: String? = "LSB" uplinkMode: String? = "LSB"
) = SatRadio( ) = SatRadio(
uuid = "linear", info = info, isAlive = true, uuid = uuid, info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh, downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345 uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
@@ -68,11 +69,93 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry)) assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
} }
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test @Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() { fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder())) assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
} }
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test @Test
fun computeUplinkFromDownlink_linear_noDoppler() { fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder() val xpdr = linearTransponder()
@@ -199,7 +199,8 @@
\n• Dave Moten (predict4java) \n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict) \n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak) \n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string> \n• Libre Space Foundation (SatNOGS)
\n• BG7NTA (CW decoding feature)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string> <string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources> </resources>
@@ -175,7 +175,40 @@
\n• Alexandru Csete (Gpredict) \n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak) \n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS) \n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!</string> \n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA(CW解码功能)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- 匹配页 Mutual match page -->
<string name="mutual_title">过境匹配</string>
<string name="mutual_station_you">你的位置</string>
<string name="mutual_station_opposite">友台位置</string>
<string name="mutual_label_you">你</string>
<string name="mutual_label_opposite">友台</string>
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
<string name="mutual_status_error">请修正提示后重新查询</string>
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
<string name="mutual_status_idle">输入双方位置后点击查询</string>
<string name="mutual_chip_calculating">计算中</string>
<string name="mutual_chip_error">有错误</string>
<string name="mutual_chip_matches">%d 个匹配</string>
<string name="mutual_chip_none">无匹配</string>
<string name="mutual_chip_idle">待查询</string>
<string name="mutual_use_current">填入当前位置</string>
<string name="mutual_lat">纬度</string>
<string name="mutual_lon">经度</string>
<string name="mutual_grid">网格</string>
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
<string name="mutual_min_elevation">最小仰角:%d°</string>
<string name="mutual_time_range">时间范围</string>
<string name="mutual_calculating">计算中…</string>
<string name="mutual_query">查询匹配</string>
<string name="mutual_elevation_you">你:%d°</string>
<string name="mutual_elevation_opposite">友台:%d°</string>
<string name="mutual_both_tracks">双方轨迹</string>
<string name="mutual_open_radar">查看雷达</string>
<string name="mutual_station_a">站点A</string>
<string name="mutual_station_b">站点B</string>
</resources> </resources>
@@ -211,7 +211,40 @@
\n* Dr T.S. Kelso (Celestrak) \n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS) \n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors! \n* xdsopl and Robot36 contributors!
\n* BG7NTA (CW decoding feature)
</string> </string>
<string name="prefs_outro_license">The app comes with no warranty</string> <string name="prefs_outro_license">The app comes with no warranty</string>
<!-- Mutual match page -->
<string name="mutual_title">Pass Match</string>
<string name="mutual_station_you">Your Station</string>
<string name="mutual_station_opposite">Opposite Station</string>
<string name="mutual_label_you">You</string>
<string name="mutual_label_opposite">Opposite</string>
<string name="mutual_status_calculating">Calculating mutual visibility windows…</string>
<string name="mutual_status_error">Fix the reported issues and retry</string>
<string name="mutual_status_matches">%d matches found · tap a card to expand</string>
<string name="mutual_status_none">No mutual windows found. Adjust time or min elevation and retry</string>
<string name="mutual_status_idle">Enter both stations, then tap Query</string>
<string name="mutual_chip_calculating">Calculating</string>
<string name="mutual_chip_error">Error</string>
<string name="mutual_chip_matches">%d matches</string>
<string name="mutual_chip_none">No matches</string>
<string name="mutual_chip_idle">Idle</string>
<string name="mutual_use_current">Use current</string>
<string name="mutual_lat">Lat</string>
<string name="mutual_lon">Lon</string>
<string name="mutual_grid">Grid</string>
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
<string name="mutual_min_elevation">Min elevation: %d°</string>
<string name="mutual_time_range">Time range</string>
<string name="mutual_calculating">Calculating…</string>
<string name="mutual_query">Query Match</string>
<string name="mutual_elevation_you">You: %d°</string>
<string name="mutual_elevation_opposite">Opposite: %d°</string>
<string name="mutual_both_tracks">Both tracks</string>
<string name="mutual_open_radar">Open Radar</string>
<string name="mutual_station_a">Station A</string>
<string name="mutual_station_b">Station B</string>
</resources> </resources>
@@ -139,7 +139,7 @@ public class MainActivity {
public void onResume() { public void onResume() {
android.content.SharedPreferences prefs = mActivity.getSharedPreferences( android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE); mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
String messageType = prefs.getString("message_type", "general_text"); String messageType = prefs.getString("message_type", "ham_radio_qso");
b bVar = this.f11037F; b bVar = this.f11037F;
if (bVar != null) { if (bVar != null) {
bVar.f623s.set(messageType.equals("ham_radio_qso")); bVar.f623s.set(messageType.equals("ham_radio_qso"));
@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.ui.res.stringResource
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.padding import androidx.compose.foundation.layout.padding
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
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.presentation.R
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -226,8 +228,8 @@ fun ElevationCurveChart(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
Text("● 站点A", color = colorA, fontSize = 12.sp) Text(stringResource(R.string.mutual_station_a), color = colorA, fontSize = 12.sp)
Text("● 站点B", color = colorB, fontSize = 12.sp) Text(stringResource(R.string.mutual_station_b), color = colorB, fontSize = 12.sp)
} }
} }
} }
@@ -57,8 +57,8 @@ private const val STROKE_WIDTH = 2.5f
fun MutualRadarView( fun MutualRadarView(
trackSamples: List<TrackSample>, trackSamples: List<TrackSample>,
progress: Float = 0.5f, progress: Float = 0.5f,
labelA: String = "站点A", labelA: String = "Station A",
labelB: String = "站点B", labelB: String = "Station B",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (trackSamples.isEmpty()) return if (trackSamples.isEmpty()) return
@@ -90,7 +90,7 @@ fun MutualScreen(
}, },
topInfo = { topInfo = {
Text( Text(
text = "过境匹配", text = stringResource(R.string.mutual_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -198,7 +198,7 @@ private fun MutualContent(
) { ) {
if (isVertical) { if (isVertical) {
StationInputCard( StationInputCard(
title = "你的位置", title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary, titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat, lat = state.stationALat,
lon = state.stationALon, lon = state.stationALon,
@@ -214,7 +214,7 @@ private fun MutualContent(
currentPositionAction = onUseCurrentPosition currentPositionAction = onUseCurrentPosition
) )
StationInputCard( StationInputCard(
title = "友台位置", title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary, titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat, lat = state.stationBLat,
lon = state.stationBLon, lon = state.stationBLon,
@@ -234,7 +234,7 @@ private fun MutualContent(
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
StationInputCard( StationInputCard(
title = "你的位置", title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary, titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat, lat = state.stationALat,
lon = state.stationALon, lon = state.stationALon,
@@ -251,7 +251,7 @@ private fun MutualContent(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
StationInputCard( StationInputCard(
title = "友台位置", title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary, titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat, lat = state.stationBLat,
lon = state.stationBLon, lon = state.stationBLon,
@@ -294,8 +294,8 @@ private fun MutualContent(
startTime = pass.startTime, startTime = pass.startTime,
endTime = pass.endTime, endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0), maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
labelA = "你", labelA = stringResource(R.string.mutual_label_you),
labelB = "友台" labelB = stringResource(R.string.mutual_label_opposite)
) )
MutualPassCard( MutualPassCard(
pass = pass, pass = pass,
@@ -341,20 +341,22 @@ private fun MutualStatusChip(state: MutualUiState) {
} }
} }
@Composable
private fun mutualStatusText(state: MutualUiState): String = when { private fun mutualStatusText(state: MutualUiState): String = when {
state.isCalculating -> "正在计算双方共同可见窗口…" state.isCalculating -> stringResource(R.string.mutual_status_calculating)
state.errorMessage != null -> "请修正提示后重新查询" state.errorMessage != null -> stringResource(R.string.mutual_status_error)
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线" state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_status_matches, state.mutualPasses.size)
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试" state.hasSearched -> stringResource(R.string.mutual_status_none)
else -> "输入双方位置后点击查询" else -> stringResource(R.string.mutual_status_idle)
} }
@Composable
private fun mutualStatusChipText(state: MutualUiState): String = when { private fun mutualStatusChipText(state: MutualUiState): String = when {
state.isCalculating -> "计算中" state.isCalculating -> stringResource(R.string.mutual_chip_calculating)
state.errorMessage != null -> "有错误" state.errorMessage != null -> stringResource(R.string.mutual_chip_error)
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配" state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_chip_matches, state.mutualPasses.size)
state.hasSearched -> "无匹配" state.hasSearched -> stringResource(R.string.mutual_chip_none)
else -> "待查询" else -> stringResource(R.string.mutual_chip_idle)
} }
@Composable @Composable
@@ -393,7 +395,7 @@ private fun StationInputCard(
) )
if (currentPositionAction != null) { if (currentPositionAction != null) {
OutlinedButton(onClick = currentPositionAction) { OutlinedButton(onClick = currentPositionAction) {
Text("填入当前位置", style = MaterialTheme.typography.bodySmall) Text(stringResource(R.string.mutual_use_current), style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@@ -405,7 +407,7 @@ private fun StationInputCard(
OutlinedTextField( OutlinedTextField(
value = lat, value = lat,
onValueChange = onLatChange, onValueChange = onLatChange,
label = { Text("纬度") }, label = { Text(stringResource(R.string.mutual_lat)) },
placeholder = { Text(latPlaceholder) }, placeholder = { Text(latPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true, singleLine = true,
@@ -414,7 +416,7 @@ private fun StationInputCard(
OutlinedTextField( OutlinedTextField(
value = lon, value = lon,
onValueChange = onLonChange, onValueChange = onLonChange,
label = { Text("经度") }, label = { Text(stringResource(R.string.mutual_lon)) },
placeholder = { Text(lonPlaceholder) }, placeholder = { Text(lonPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true, singleLine = true,
@@ -425,15 +427,15 @@ private fun StationInputCard(
OutlinedTextField( OutlinedTextField(
value = grid, value = grid,
onValueChange = onGridChange, onValueChange = onGridChange,
label = { Text("网格") }, label = { Text(stringResource(R.string.mutual_grid)) },
placeholder = { Text(gridPlaceholder) }, placeholder = { Text(gridPlaceholder) },
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") }, supportingText = { Text(stringResource(R.string.mutual_grid_hint)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text( Text(
text = "最小仰角:${minElev.toInt()}°", text = stringResource(R.string.mutual_min_elevation, minElev.toInt()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -461,7 +463,7 @@ private fun MatchSearchCard(
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Text( Text(
text = "时间范围", text = stringResource(R.string.mutual_time_range),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
@@ -492,7 +494,7 @@ private fun MatchSearchCard(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text(if (isCalculating) "计算中..." else "查询匹配") Text(if (isCalculating) stringResource(R.string.mutual_calculating) else stringResource(R.string.mutual_query))
} }
} }
} }
@@ -558,12 +560,12 @@ private fun MutualPassCard(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = "你: ${pass.maxElevationA}°", text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "友台: ${pass.maxElevationB}°", text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary color = MaterialTheme.colorScheme.tertiary
) )
@@ -579,7 +581,7 @@ private fun MutualPassCard(
// Dual-station radar track (polar plot) // Dual-station radar track (polar plot)
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
Text( Text(
text = "双方轨迹", text = stringResource(R.string.mutual_both_tracks),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -587,8 +589,8 @@ private fun MutualPassCard(
MutualRadarView( MutualRadarView(
trackSamples = visibleTracks, trackSamples = visibleTracks,
progress = dragProgress, progress = dragProgress,
labelA = "你", labelA = stringResource(R.string.mutual_label_you),
labelB = "友台" labelB = stringResource(R.string.mutual_label_opposite)
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
@@ -613,7 +615,7 @@ private fun MutualPassCard(
modifier = Modifier.height(16.dp).width(16.dp) modifier = Modifier.height(16.dp).width(16.dp)
) )
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("查看雷达") Text(stringResource(R.string.mutual_open_radar))
} }
} }
} }
@@ -158,13 +158,13 @@ class MutualViewModel(
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid) var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
if (posA == null || posB == null) { if (posA == null || posB == null) {
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标或网格(4/6/8位)") } _uiState.update { it.copy(errorMessage = "Please enter valid coordinates or a Maidenhead grid (4/6/8 chars)") }
return return
} }
val satellites = satelliteRepo.satellites.value val satellites = satelliteRepo.satellites.value
if (satellites.isEmpty()) { if (satellites.isEmpty()) {
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") } _uiState.update { it.copy(errorMessage = "No satellite data. Select satellites from the list first.") }
return return
} }
@@ -193,9 +193,9 @@ class MutualViewModel(
val passCount = satelliteRepo.passes.value.size val passCount = satelliteRepo.passes.value.size
val satCount = satellites.size val satCount = satellites.size
when { when {
passCount == 0 -> "过境列表为空,请先在卫星列表中选择卫星" passCount == 0 -> "Pass list is empty. Select satellites from the list first."
satCount == 0 -> "没有已选中的卫星" satCount == 0 -> "No satellites selected."
else -> "未找到共同过境(${passCount}个过境,${satCount}颗卫星)" else -> "No mutual passes found (${passCount} passes, ${satCount} satellites)."
} }
} else null } else null
@@ -148,6 +148,7 @@ fun CalculatorPage(
) { ) {
val calculatorTransceivers = remember(transceivers) { val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
.let(DopplerFrequencyCalculator::deduplicateTransponders)
} }
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid } calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }