mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-13 07:27:26 +00:00
feat(logbook): 新建按钮 + 详情精简 + 新建草稿自动填标称频率
- 日志本左下按钮由「取消」改为「新建」,多选态显示「退出选择」;新建打开空白通联详情, 保存即插入(id=0 → save 插入),数据通路未动。SharedDialog 新增 cancelText 参数 (默认仍为「取消」,其它弹窗不受影响) - 通联日志详情去掉发送/接收报告与备注三个字段:保存时不再覆盖这三个值(存量值保留、 导出照旧,仅不可再编辑);对方网格保留但改为只读——未确认 QSO 不由 App 写对方网格 - 新建草稿选定卫星后自动填标称(不含多普勒)上下行:优先 FM voice repeater,否则取首个 上下行齐全转发器的频段中值;卫星变化会重填,但手工改过的不覆盖(AO-7 换频段场景); 仅作用于新建草稿(id==0),编辑已有记录不自动填 - domain 新增 NominalFrequencies 与 nominalTransceiver()/catNumForName()(名字按卫星身份 匹配,SAUDISAT 1C ↔ SO-50);ISatelliteRepo/SatelliteRepo 增加 nominalFrequencies(name); SettingsViewModel 注入 satelliteRepo;新增单测 NominalTransceiverTest 6/6 通过
This commit is contained in:
15 files changed
+315
-35
No files matched your search
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
@@ -25,8 +26,12 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.utility.catNumForName
|
||||
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.nominalTransceiver
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -65,6 +70,12 @@ class SatelliteRepo(
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun nominalFrequencies(name: String): NominalFrequencies? {
|
||||
val catnum = localStorage.getEntriesNames().catNumForName(name) ?: return null
|
||||
val radio = localStorage.getRadiosWithId(catnum).nominalTransceiver() ?: return null
|
||||
return NominalFrequencies(radio.uplinkHz(), radio.downlinkHz())
|
||||
}
|
||||
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/**
|
||||
* Nominal (no Doppler shift) uplink/downlink pair of the transceiver a contact on a satellite is
|
||||
* made through. The logbook's blank draft fills these in so a hand-made record carries a
|
||||
* frequency — LoTW derives BAND from it and rejects a record that has none.
|
||||
*/
|
||||
data class NominalFrequencies(val uplinkHz: Long?, val downlinkHz: Long?)
|
||||
+10
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
@@ -66,6 +67,15 @@ interface ISatelliteRepo {
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
/**
|
||||
* Nominal (no Doppler) uplink/downlink of the transceiver a contact on [name] is made
|
||||
* through: the FM voice repeater when the bird has one, else the first live transceiver
|
||||
* publishing both bands. Names match through satellite identities, so the tracker spelling
|
||||
* ("SAUDISAT 1C") finds the same entry as the ARRL name ("SO-50"). Null when the name
|
||||
* resolves to no satellite, or the satellite publishes no usable pair.
|
||||
*/
|
||||
suspend fun nominalFrequencies(name: String): NominalFrequencies?
|
||||
|
||||
/** Satellite catnums whose radios carry any of the given downlink modes.
|
||||
* Empty modes -> empty result; used to filter the mutual-match satellite
|
||||
* set to transponder satellites (FM voice / linear). */
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
|
||||
/**
|
||||
* The transceiver a contact on this satellite is logged through: the FM voice repeater when the
|
||||
* bird carries one (the radar Log tab logs through it too), else the first entry publishing both
|
||||
* bands. The second case is what gives a linear satellite — AO-7, RS-44 — a starting pair; the
|
||||
* operator edits the band when the bird happens to be worked the other way round.
|
||||
*
|
||||
* Dead entries are not skipped: a stale alive flag (AO-7 while its battery is flat) must not stop
|
||||
* the prefill, and the operator can always overwrite what gets filled in.
|
||||
*/
|
||||
fun List<SatRadio>.nominalTransceiver(): SatRadio? =
|
||||
voiceRepeater() ?: firstOrNull { it.uplinkHz() != null && it.downlinkHz() != null }
|
||||
|
||||
/**
|
||||
* Catalog number of the entry whose identity matches [name], looked up in a catnum -> name map
|
||||
* holding the tracker spellings ("SAUDISAT 1C" is where SO-50 lives).
|
||||
*/
|
||||
fun Map<Int, String>.catNumForName(name: String): Int? {
|
||||
val wanted = satelliteIdentity(name)
|
||||
if (wanted.isBlank()) return null
|
||||
return entries.firstOrNull { satelliteIdentity(it.value) == wanted }?.key
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class NominalTransceiverTest {
|
||||
|
||||
@Test
|
||||
fun `the voice repeater wins over a linear entry listed first`() {
|
||||
val radios = listOf(
|
||||
radio("lin", "Mode U/V Linear", uplink = 435_100_000L, downlink = 145_950_000L, mode = "USB"),
|
||||
radio("fm", "Mode V/U FM Voice CTCSS 67.0 Hz", uplink = 145_850_000L, downlink = 436_795_000L)
|
||||
)
|
||||
assertEquals("fm", radios.nominalTransceiver()?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a linear bird falls back to the first entry with both bands`() {
|
||||
val radios = listOf(
|
||||
radio("beacon", "Beacon", uplink = null, downlink = 145_950_000L, mode = "CW"),
|
||||
radio("vu", "Mode V/U Linear", uplink = 145_950_000L, downlink = 435_600_000L, mode = "USB")
|
||||
)
|
||||
assertEquals("vu", radios.nominalTransceiver()?.uuid)
|
||||
assertEquals(145_950_000L, radios.nominalTransceiver()?.uplinkHz())
|
||||
assertEquals(435_600_000L, radios.nominalTransceiver()?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a published passband yields its middle`() {
|
||||
val radios = listOf(
|
||||
radio(
|
||||
"vu", "Mode V/U Linear", uplink = 435_000_000L, downlink = 145_900_000L,
|
||||
uplinkHigh = 435_300_000L, downlinkHigh = 146_000_000L, mode = "USB"
|
||||
)
|
||||
)
|
||||
assertEquals(435_150_000L, radios.nominalTransceiver()?.uplinkHz())
|
||||
assertEquals(145_950_000L, radios.nominalTransceiver()?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stale alive flag does not stop the prefill`() {
|
||||
val radios = listOf(
|
||||
radio("ao7", "Mode U/V Linear", uplink = 432_150_000L, downlink = 145_950_000L, isAlive = false, mode = "USB")
|
||||
)
|
||||
assertEquals("ao7", radios.nominalTransceiver()?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no transceiver with both bands yields null`() {
|
||||
val radios = listOf(
|
||||
radio("beacon", "Beacon", uplink = null, downlink = 145_950_000L, mode = "CW"),
|
||||
radio("vu", "Mode V/U Linear", uplink = null, downlink = 435_600_000L, mode = "USB")
|
||||
)
|
||||
assertNull(radios.nominalTransceiver())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the catnum lookup matches through the alias table`() {
|
||||
val names = mapOf(
|
||||
27607 to "SAUDISAT 1C",
|
||||
7530 to "AO-7 (AMSAT-OSCAR 7)",
|
||||
25544 to "ISS (ZARYA)"
|
||||
)
|
||||
assertEquals(27607, names.catNumForName("SO-50"))
|
||||
assertEquals(7530, names.catNumForName("AO-7"))
|
||||
assertEquals(25544, names.catNumForName("ARISS"))
|
||||
assertNull(names.catNumForName("NOT A SATELLITE"))
|
||||
}
|
||||
|
||||
private fun radio(
|
||||
uuid: String,
|
||||
info: String,
|
||||
uplink: Long?,
|
||||
downlink: Long?,
|
||||
isAlive: Boolean = true,
|
||||
uplinkHigh: Long? = null,
|
||||
downlinkHigh: Long? = null,
|
||||
mode: String = "FM"
|
||||
) = SatRadio(
|
||||
uuid = uuid,
|
||||
info = info,
|
||||
isAlive = isAlive,
|
||||
downlinkLow = downlink,
|
||||
downlinkHigh = downlinkHigh,
|
||||
downlinkMode = mode,
|
||||
uplinkLow = uplink,
|
||||
uplinkHigh = uplinkHigh,
|
||||
uplinkMode = mode,
|
||||
isInverted = false,
|
||||
catnum = 1
|
||||
)
|
||||
}
|
||||
+6
-2
@@ -351,6 +351,7 @@ fun SharedDialog(
|
||||
onAccept: () -> Unit,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
cancelText: String? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedDialog(
|
||||
@@ -359,7 +360,8 @@ fun SharedDialog(
|
||||
onCancel = onCancel,
|
||||
onAccept = onAccept,
|
||||
acceptText = acceptText,
|
||||
acceptEnabled = acceptEnabled
|
||||
acceptEnabled = acceptEnabled,
|
||||
cancelText = cancelText
|
||||
) { _ ->
|
||||
content()
|
||||
}
|
||||
@@ -373,6 +375,8 @@ fun SharedDialog(
|
||||
onAccept: (() -> Unit)? = null,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
/** Label of the leading button; [R.string.btn_cancel] when null. */
|
||||
cancelText: String? = null,
|
||||
titleFontSize: Int = 16,
|
||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
@@ -385,7 +389,7 @@ fun SharedDialog(
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
if (onCancel != null) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
CardButton(onClick = onCancel, text = cancelText ?: stringResource(R.string.btn_cancel))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
|
||||
+50
-29
@@ -36,6 +36,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
@@ -61,10 +63,13 @@ private const val DATE_PATTERN = "yyyy-MM-dd"
|
||||
private const val TIME_PATTERN = "HH:mm:ss"
|
||||
|
||||
/**
|
||||
* Edit every field of a recorded QSO (the record list rows in the radar Log tab and
|
||||
* the logbook dialog both open this). Frequencies are entered in MHz and the band is
|
||||
* re-derived from them, because that derivation is what LoTW validates: a record with
|
||||
* no uplink frequency has no BAND and cannot be signed.
|
||||
* Edit a recorded QSO — the record list rows in the radar Log tab and the logbook dialog
|
||||
* both open this, and the logbook's Create button opens it on a blank draft. The opposite
|
||||
* grid is read-only: an unconfirmed QSO has no opposite grid of its own, LoTW's confirmation
|
||||
* is what brings the real one in. The sent/received reports and the comment are not part of
|
||||
* the log any more, so they are neither shown nor rewritten here. Frequencies are entered in
|
||||
* MHz and the band is re-derived from them, because that derivation is what LoTW validates:
|
||||
* a record with no uplink frequency has no BAND and cannot be signed.
|
||||
*
|
||||
* The satellite name is restricted to ARRL's catalogue ([satelliteCandidates], from
|
||||
* config.tq6): typing shows fuzzy suggestions, the picker lists the whole catalogue, and
|
||||
@@ -75,6 +80,8 @@ private const val TIME_PATTERN = "HH:mm:ss"
|
||||
fun QsoEditDialog(
|
||||
record: QsoRecord,
|
||||
satelliteCandidates: List<String> = emptyList(),
|
||||
/** Nominal (no Doppler) uplink/downlink of a named satellite, prefilled into a blank draft. */
|
||||
nominalFrequencies: suspend (String) -> NominalFrequencies? = { null },
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (QsoRecord) -> Unit
|
||||
) {
|
||||
@@ -90,10 +97,6 @@ fun QsoEditDialog(
|
||||
var modeText by remember(record.id) { mutableStateOf(record.displayMode) }
|
||||
var satelliteText by remember(record.id) { mutableStateOf(record.satelliteName) }
|
||||
var pickerOpen by remember(record.id) { mutableStateOf(false) }
|
||||
var sentText by remember(record.id) { mutableStateOf(record.sentReport) }
|
||||
var receivedText by remember(record.id) { mutableStateOf(record.receivedReport) }
|
||||
var gridText by remember(record.id) { mutableStateOf(record.theirGrid) }
|
||||
var commentText by remember(record.id) { mutableStateOf(record.comment) }
|
||||
var error by remember(record.id) { mutableStateOf("") }
|
||||
|
||||
// ARRL name the typed tracker name resolves to ("SAUDISAT 1C" -> "SO-50"), if any.
|
||||
@@ -104,6 +107,28 @@ fun QsoEditDialog(
|
||||
LoTWSatelliteAliases.suggestions(satelliteText, satelliteCandidates)
|
||||
}
|
||||
|
||||
// A blank draft gets the satellite's nominal pair as soon as its name resolves — the same
|
||||
// frequencies the radar Log tab logs through, with no Doppler shift applied. Listed records
|
||||
// keep whatever they were logged with, and a pair the operator edited by hand survives a
|
||||
// satellite change: AO-7 is worked on either band, so a manual band switch must stick.
|
||||
var autoTx by remember(record.id) { mutableStateOf("") }
|
||||
var autoRx by remember(record.id) { mutableStateOf("") }
|
||||
val fillName = officialSatellite ?: satelliteText.trim()
|
||||
LaunchedEffect(record.id, fillName) {
|
||||
if (record.id != 0L || fillName.isBlank()) return@LaunchedEffect
|
||||
val nominal = nominalFrequencies(fillName) ?: return@LaunchedEffect
|
||||
val tx = formatMhz(nominal.uplinkHz)
|
||||
val rx = formatMhz(nominal.downlinkHz)
|
||||
if (tx.isNotBlank() && (txText.isBlank() || txText == autoTx)) {
|
||||
txText = tx
|
||||
autoTx = tx
|
||||
}
|
||||
if (rx.isNotBlank() && (rxText.isBlank() || rxText == autoRx)) {
|
||||
rxText = rx
|
||||
autoRx = rx
|
||||
}
|
||||
}
|
||||
|
||||
val txHz = parseMhz(txText)
|
||||
val rxHz = parseMhz(rxText)
|
||||
val bandText = listOf(frequencyBand(txHz), frequencyBand(rxHz)).filter { it.isNotBlank() }.distinct()
|
||||
@@ -207,22 +232,7 @@ fun QsoEditDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_sent), sentText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
sentText = it
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_received), receivedText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
receivedText = it
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_grid), gridText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
gridText = it.uppercase(Locale.US)
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_comment), commentText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||
commentText = it
|
||||
}
|
||||
}
|
||||
ReadOnlyField(stringResource(R.string.qso_edit_grid), record.theirGrid)
|
||||
if (txHz == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.qso_edit_warn_no_freq),
|
||||
@@ -256,9 +266,6 @@ fun QsoEditDialog(
|
||||
theirCallsign = callsign.trim().uppercase(Locale.US),
|
||||
startUtcMillis = millis,
|
||||
endUtcMillis = record.endUtcMillis?.let { millis },
|
||||
theirGrid = gridText.trim().uppercase(Locale.US),
|
||||
sentReport = sentText.trim(),
|
||||
receivedReport = receivedText.trim(),
|
||||
txFrequencyHz = txHz,
|
||||
rxFrequencyHz = rxHz,
|
||||
band = frequencyBand(txHz),
|
||||
@@ -266,8 +273,7 @@ fun QsoEditDialog(
|
||||
mode = if (mode == "FT4") "MFSK" else mode,
|
||||
submode = if (mode == "FT4") "FT4" else "",
|
||||
satelliteName = satellite,
|
||||
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT",
|
||||
comment = commentText.trim()
|
||||
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -348,6 +354,21 @@ internal fun filteredCandidates(query: String, candidates: List<String>): List<S
|
||||
return matches.ifEmpty { candidates }
|
||||
}
|
||||
|
||||
/** Non-editable look-alike of [EditField], for values the operator must not rewrite. */
|
||||
@Composable
|
||||
private fun ReadOnlyField(label: String, value: String, modifier: Modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = value.ifBlank { "—" },
|
||||
onValueChange = {},
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
readOnly = true,
|
||||
enabled = false,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier = modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditField(
|
||||
label: String,
|
||||
|
||||
@@ -225,6 +225,8 @@
|
||||
<string name="btn_delete">删除</string>
|
||||
<string name="prefs_logbook_title">日志本</string>
|
||||
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||
<string name="prefs_logbook_new">新建</string>
|
||||
<string name="prefs_logbook_exit_selection">退出选择</string>
|
||||
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||
<string name="prefs_logbook_close">关闭</string>
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
|
||||
@@ -215,6 +215,8 @@
|
||||
<string name="btn_delete">删除</string>
|
||||
<string name="prefs_logbook_title">日志本</string>
|
||||
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||
<string name="prefs_logbook_new">新建</string>
|
||||
<string name="prefs_logbook_exit_selection">退出选择</string>
|
||||
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||
<string name="prefs_logbook_close">关闭</string>
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
|
||||
@@ -244,6 +244,8 @@
|
||||
<string name="btn_delete">Delete</string>
|
||||
<string name="prefs_logbook_title">Logbook</string>
|
||||
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
|
||||
<string name="prefs_logbook_new">New</string>
|
||||
<string name="prefs_logbook_exit_selection">Exit selection</string>
|
||||
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
|
||||
<string name="prefs_logbook_close">Close</string>
|
||||
<string name="prefs_logbook_upload">Upload</string>
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -63,6 +64,7 @@ class FakeSatelliteRepo(
|
||||
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
|
||||
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
|
||||
override suspend fun nominalFrequencies(name: String): NominalFrequencies? = TODO()
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
+17
-2
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
@@ -90,6 +91,10 @@ fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
|
||||
fun LogbookDialog(
|
||||
records: List<QsoRecord>,
|
||||
satelliteCandidates: List<String>,
|
||||
/** Blank draft the header's Create button opens (own callsign/grid come from the certificate). */
|
||||
newRecord: () -> QsoRecord,
|
||||
/** Nominal frequencies handed to the edit dialog, which prefills a blank draft with them. */
|
||||
nominalFrequencies: suspend (String) -> NominalFrequencies? = { null },
|
||||
uploadBusy: Boolean,
|
||||
uploadMessage: String,
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
|
||||
@@ -125,12 +130,21 @@ fun LogbookDialog(
|
||||
val swipeController = rememberSwipeController()
|
||||
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||
LaunchedEffect(selectionMode) { if (selectionMode) swipeController.close() }
|
||||
// Record currently open in the edit dialog (null when closed).
|
||||
// Record currently open in the edit dialog — a listed row, or the blank draft the Create
|
||||
// button just made. A draft carries id = 0, so saving it inserts a new row.
|
||||
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
|
||||
// Header leading button: Create while idle, Leave-selection while rows are checked.
|
||||
// Cancel is gone; its two jobs are split over those two labels.
|
||||
val onHeader = if (selectionMode) onExitSelection else ({ editTarget = newRecord() })
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_logbook_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = if (selectionMode) onExitSelection else onDismiss,
|
||||
onCancel = onHeader,
|
||||
cancelText = if (selectionMode) {
|
||||
stringResource(R.string.prefs_logbook_exit_selection)
|
||||
} else {
|
||||
stringResource(R.string.prefs_logbook_new)
|
||||
},
|
||||
onAccept = if (selectionMode) onResubmitSelected else onUpload,
|
||||
acceptText = if (selectionMode) {
|
||||
stringResource(R.string.prefs_logbook_resubmit, selectedIds.size)
|
||||
@@ -272,6 +286,7 @@ fun LogbookDialog(
|
||||
QsoEditDialog(
|
||||
record = target,
|
||||
satelliteCandidates = satelliteCandidates,
|
||||
nominalFrequencies = nominalFrequencies,
|
||||
onDismiss = { editTarget = null },
|
||||
onSave = { updated ->
|
||||
onEdit(updated)
|
||||
|
||||
+21
@@ -67,7 +67,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
|
||||
@@ -102,6 +105,7 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
|
||||
SettingsScreen(
|
||||
uiState = uiState,
|
||||
onAction = viewModel::onAction,
|
||||
nominalFrequencies = viewModel::nominalFrequencies,
|
||||
onOpenGridFinder = onOpenGridFinder,
|
||||
pendingStationGrid = pendingStationGrid.orEmpty(),
|
||||
onFixStationGrid = { grids ->
|
||||
@@ -115,6 +119,7 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
|
||||
private fun SettingsScreen(
|
||||
uiState: SettingsState,
|
||||
onAction: (SettingsAction) -> Unit,
|
||||
nominalFrequencies: suspend (String) -> NominalFrequencies? = { null },
|
||||
onOpenGridFinder: () -> Unit,
|
||||
pendingStationGrid: String = "",
|
||||
onFixStationGrid: (List<String>) -> Unit = {},
|
||||
@@ -292,6 +297,22 @@ private fun SettingsScreen(
|
||||
LogbookDialog(
|
||||
records = uiState.logbookRecords.filter { stationFilter == null || it.wavelogStation == stationFilter },
|
||||
satelliteCandidates = uiState.satelliteCatalog,
|
||||
newRecord = {
|
||||
// Blank draft: minute-truncated "now" (the radar Log tab's granularity), the
|
||||
// certificate's callsign and the station location's first grid, so the record
|
||||
// is uploadable as it stands.
|
||||
val now = System.currentTimeMillis() / 60_000L * 60_000L
|
||||
QsoRecord(
|
||||
startUtcMillis = now,
|
||||
endUtcMillis = now,
|
||||
theirCallsign = "",
|
||||
myCallsign = uiState.lotwCertificate?.callsign.orEmpty(),
|
||||
myGrid = uiState.lotwStation?.grid?.substringBefore(',')?.trim()
|
||||
?.uppercase(Locale.US).orEmpty(),
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
},
|
||||
nominalFrequencies = nominalFrequencies,
|
||||
uploadBusy = uiState.logbookUploadBusy,
|
||||
uploadMessage = uiState.logbookUploadMessage,
|
||||
preview = uiState.logbookPreview,
|
||||
|
||||
+11
@@ -23,8 +23,10 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
@@ -72,6 +74,7 @@ class SettingsViewModel(
|
||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
||||
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val sensorsRepo: ISensorsRepo,
|
||||
private val apkFile: File,
|
||||
private val showToast: IShowToast
|
||||
@@ -569,6 +572,13 @@ class SettingsViewModel(
|
||||
|
||||
// region Logbook + LoTW upload configuration
|
||||
|
||||
/**
|
||||
* Nominal (no Doppler) uplink/downlink of the satellite [name] is logged through — the
|
||||
* logbook's Create draft fills them into a blank record, so the operator does not have to
|
||||
* look the pair up and a record without a frequency (which LoTW rejects) cannot be saved.
|
||||
*/
|
||||
suspend fun nominalFrequencies(name: String): NominalFrequencies? = satelliteRepo.nominalFrequencies(name)
|
||||
|
||||
private fun refreshLogbook() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookRecords = qsoRepository.records.first()) }
|
||||
@@ -1030,6 +1040,7 @@ class SettingsViewModel(
|
||||
lotwRepo = container.lotwRepo,
|
||||
qsoRepository = container.qsoRepository,
|
||||
lotwUploadRepository = container.lotwUploadRepository,
|
||||
satelliteRepo = container.satelliteRepo,
|
||||
sensorsRepo = container.provideSensorsRepo(),
|
||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||
showToast = container.provideShowToast()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "585"
|
||||
appVersionCode = "586"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.11.10"
|
||||
appVersionName = "4.4.8-ba7opf.11.11"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user