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:
atsunatsu committed 2026-10-12 17:34:09 +08:00
1 parent 6d1b5c2798
commit 9551de0d1e
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)
@@ -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?)
@@ -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). */
@@ -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
}
@@ -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
)
}
@@ -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,
@@ -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()
@@ -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)
@@ -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,
@@ -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()
+2 -2
View File
@@ -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