mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-03 03:17:21 +00:00
feat: LoTW upload + logbook (port from Iu-yang1's fork)
- Port TQ8 signing/upload pipeline (core/data/lotw: LoTWConfig, LoTWKeyMaterial,
LoTWSigner, LoTWStorage, LoTWUploadRepository) with bundled ARRL config.tq6
- Multi-grid station locations: primary grid in GRIDSQUARE + full set in
MY_VUCC_GRIDS (unsigned tSTATION extra, IOTA precedent); signer matches
myGrid against the whole grid set
- Satellite-name normalization: SatNOGS names ("SO-50 (SaudiOSCAR 50)") resolve
to official LoTW names by exact-then-substring match
- Logbook storage: separate QsoDatabase (version 1, no migration on existing
installs), QsoRepository with dedupe/confirmation merge; LoTW sync (manual +
auto) feeds confirmations into the logbook and marks local uploads confirmed
- Radar Log tab: single-callsign recording, per-satellite mode preset
(CW/SSB/FT4), English social post generation (copy/share), LoTW upload with
one-shot preview confirmation + receipts ledger
- Settings: 日志本 section (records + confirmation state, delete on tap) above
LoTW config; LoTW upload config (TrustedQSL .p12 import + password, station
grid list incl. IOTA/CQZ/ITUZ)
- i18n strings (en/zh)
This commit is contained in:
1 parent
d33629f015
commit
8e549cd43c
36 files changed
+2696
-4
No files matched your search
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
|
||||
private const val LOG_WINDOW_MS = 24 * 3_600_000L
|
||||
|
||||
@Composable
|
||||
fun LogPage(
|
||||
uiState: RadarState,
|
||||
logViewModel: LogViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
|
||||
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
|
||||
}
|
||||
val isLinear = selectedRadio != null &&
|
||||
DopplerFrequencyCalculator.isNamedLinearTransponder(selectedRadio)
|
||||
val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
|
||||
val satName = uiState.currentPass?.name?.trim().orEmpty()
|
||||
|
||||
LaunchedEffect(catnum) {
|
||||
if (catnum != 0) logViewModel.selectSatellite(catnum)
|
||||
}
|
||||
|
||||
val txHz = remember(selectedRadio) {
|
||||
selectedRadio?.uplinkLow?.let { low ->
|
||||
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
|
||||
}
|
||||
}
|
||||
val rxHz = remember(selectedRadio) {
|
||||
selectedRadio?.downlinkLow?.let { low ->
|
||||
selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
|
||||
}
|
||||
}
|
||||
val mode = if (isLinear) logUiState.selectedMode.ifBlank { "CW" } else "FM"
|
||||
val now = System.currentTimeMillis()
|
||||
val recent = records
|
||||
.filter { it.satelliteName.trim().equals(satName, true) && it.startUtcMillis > now - LOG_WINDOW_MS }
|
||||
.sortedByDescending { it.startUtcMillis }
|
||||
val maxElev = uiState.currentPass?.maxElevation ?: 0.0
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = logUiState.callsignInput,
|
||||
onValueChange = logViewModel::updateCallsign,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Callsign", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
|
||||
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid)
|
||||
})
|
||||
)
|
||||
Button(
|
||||
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) },
|
||||
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
|
||||
) { Text("Log") }
|
||||
}
|
||||
if (isLinear) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("CW", "SSB", "FT4").forEach { candidate ->
|
||||
FilterChip(
|
||||
selected = mode == candidate,
|
||||
onClick = { logViewModel.selectMode(catnum, candidate) },
|
||||
label = { Text(candidate, fontSize = 13.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("FM", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
if (recent.isEmpty()) {
|
||||
EmptyListCard(message = "No QSOs yet — type a callsign and tap Log")
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(recent, key = { it.id }) { record ->
|
||||
LogRecordRow(record, onDelete = { logViewModel.delete(record.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { logViewModel.generatePost(satName, maxElev) },
|
||||
enabled = recent.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("生成通联记录", fontSize = 13.sp) }
|
||||
OutlinedButton(
|
||||
onClick = logViewModel::prepareUpload,
|
||||
enabled = !logUiState.busy,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("上传 LoTW", fontSize = 13.sp) }
|
||||
}
|
||||
if (logUiState.busy) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(4.dp), strokeWidth = 2.dp)
|
||||
Text("Working…", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logUiState.postText?.let { text ->
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
PostDialog(
|
||||
text = text,
|
||||
onDismiss = logViewModel::dismissPost,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(text))
|
||||
logViewModel.dismissPost()
|
||||
},
|
||||
onShare = {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, "Share QSO log"))
|
||||
logViewModel.dismissPost()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
logUiState.preview?.let { preview ->
|
||||
UploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
)
|
||||
}
|
||||
|
||||
if (logUiState.message.isNotBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = logViewModel::clearMessage,
|
||||
title = { Text("LoTW Upload") },
|
||||
text = { Text(logUiState.message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogRecordRow(record: QsoRecord, onDelete: () -> Unit) {
|
||||
val time = remember(record.startUtcMillis) {
|
||||
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(record.startUtcMillis))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "$time ${record.theirCallsign} ${record.displayMode}",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(text = if (record.lotwConfirmed) "✓" else "", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PostDialog(
|
||||
text: String,
|
||||
onDismiss: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onShare: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("通联记录") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text, fontSize = 13.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
TextButton(onClick = onCopy) { Text("Copy") }
|
||||
TextButton(onClick = onShare) { Text("Share") }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("上传到 LoTW") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
||||
Text("${preview.count} QSO(s) · ${
|
||||
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(preview.firstUtc.let {
|
||||
runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
|
||||
}))
|
||||
} – ${
|
||||
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(preview.lastUtc.let {
|
||||
runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
|
||||
}))
|
||||
}Z", fontSize = 13.sp)
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
data class LogUiState(
|
||||
val callsignInput: String = "",
|
||||
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
|
||||
val selectedMode: String = "",
|
||||
/** Station callsign from the imported LoTW certificate ("" when none). */
|
||||
val certificateCallsign: String = "",
|
||||
/** Primary grid of the LoTW upload station location ("" when none). */
|
||||
val stationGrid: String = "",
|
||||
val preview: LoTWUploadPreview? = null,
|
||||
/** User-facing upload / record message ("" when none). */
|
||||
val message: String = "",
|
||||
val postText: String? = null,
|
||||
val busy: Boolean = false
|
||||
)
|
||||
|
||||
class LogViewModel(
|
||||
private val qsoRepository: IQsoRepository,
|
||||
private val lotwUploadRepository: ILoTWUploadRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
val records: Flow<List<QsoRecord>> = qsoRepository.records
|
||||
|
||||
private val _uiState = MutableStateFlow(LogUiState())
|
||||
val uiState: StateFlow<LogUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val cert = lotwUploadRepository.certificate()
|
||||
val station = lotwUploadRepository.station()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
certificateCallsign = cert?.callsign.orEmpty(),
|
||||
stationGrid = station?.grid.orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCallsign(value: String) =
|
||||
_uiState.update { it.copy(callsignInput = value.uppercase(Locale.US)) }
|
||||
|
||||
/** Load the per-satellite mode preset when the selected satellite changes. */
|
||||
fun selectSatellite(catnum: Int) {
|
||||
val preset = settingsRepo.getSatelliteMode(catnum)
|
||||
_uiState.update { it.copy(selectedMode = preset) }
|
||||
}
|
||||
|
||||
fun selectMode(catnum: Int, mode: String) {
|
||||
_uiState.update { it.copy(selectedMode = mode) }
|
||||
settingsRepo.setSatelliteMode(catnum, mode)
|
||||
}
|
||||
|
||||
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) {
|
||||
val call = _uiState.value.callsignInput.trim()
|
||||
if (call.isEmpty()) return
|
||||
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
|
||||
val now = System.currentTimeMillis()
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = now,
|
||||
endUtcMillis = now,
|
||||
theirCallsign = call,
|
||||
myCallsign = _uiState.value.certificateCallsign,
|
||||
myGrid = myGrid.take(6).uppercase(Locale.US),
|
||||
txFrequencyHz = txHz,
|
||||
rxFrequencyHz = rxHz,
|
||||
band = frequencyBand(txHz),
|
||||
rxBand = frequencyBand(rxHz),
|
||||
mode = if (normalizedMode == "FT4") "MFSK" else normalizedMode,
|
||||
submode = normalizedMode.takeIf { it == "FT4" }.orEmpty(),
|
||||
satelliteName = satName,
|
||||
satelliteMode = normalizedMode,
|
||||
status = QsoStatus.COMPLETE,
|
||||
propagationMode = "SAT"
|
||||
)
|
||||
viewModelScope.launch { qsoRepository.save(record) }
|
||||
_uiState.update { it.copy(callsignInput = "") }
|
||||
}
|
||||
|
||||
fun delete(id: Long) = viewModelScope.launch { qsoRepository.delete(id) }
|
||||
|
||||
/** English social post from the recorded QSOs of the current satellite (last 24h). */
|
||||
fun generatePost(satName: String, maxElev: Double) {
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
val list = qsoRepository.records.first()
|
||||
.filter { it.satelliteName.trim().equals(satName.trim(), true) }
|
||||
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
|
||||
.sortedBy { it.startUtcMillis }
|
||||
if (list.isEmpty()) return@launch
|
||||
val shortName = satName.substringBefore('(').trim().uppercase(Locale.US)
|
||||
val utc = SimpleDateFormat("yyyyMMdd|HH:mm'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val firstLine = "🛰️ $shortName | ${utc.format(Date(list.first().startUtcMillis))}"
|
||||
val elLine = "🔺 EL ${String.format(Locale.US, "%.1f", maxElev)}° | ${list.size} QSOs"
|
||||
val modeLines = list.groupBy { it.displayMode }
|
||||
.map { (mode, qsos) -> "📡 $mode: ${qsos.joinToString(" ") { it.theirCallsign }}" }
|
||||
.joinToString("\n")
|
||||
val currentGrid = settingsRepo.stationPosition.value.qthLocator.take(6)
|
||||
val logLine = "📍 Log: ${_uiState.value.stationGrid.take(4)} | Current: $currentGrid"
|
||||
val viaLine = "via Look4Sat — TNX de ${_uiState.value.certificateCallsign}"
|
||||
val tag = "#HamRadio #SatelliteQSO #" + shortName.filter { it.isLetterOrDigit() }
|
||||
val text = listOf(firstLine, elLine, modeLines, "", logLine, viaLine, tag).joinToString("\n")
|
||||
_uiState.update { it.copy(postText = text) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPost() = _uiState.update { it.copy(postText = null) }
|
||||
|
||||
fun prepareUpload() {
|
||||
if (_uiState.value.busy) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true, message = "") }
|
||||
try {
|
||||
val all = qsoRepository.records.first()
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
if (audit.pending == 0) {
|
||||
_uiState.update { it.copy(busy = false, message = "No pending QSOs to upload") }
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
_uiState.update { it.copy(busy = false, preview = preview) }
|
||||
} catch (e: LoTWOperationException) {
|
||||
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
|
||||
} catch (_: Exception) {
|
||||
_uiState.update { it.copy(busy = false, message = "Upload failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmUpload() {
|
||||
val preview = _uiState.value.preview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val result = lotwUploadRepository.upload(preview.id)
|
||||
val msg = when (result) {
|
||||
is LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
|
||||
is LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
|
||||
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
|
||||
fun clearMessage() = _uiState.update { it.copy(message = "") }
|
||||
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import kotlin.math.PI
|
||||
private enum class RadarPage(val title: String) {
|
||||
Transceivers("Transceivers"),
|
||||
Calculator("Calculator"),
|
||||
Log("Log"),
|
||||
Sstv("SSTV")
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
val logViewModel: LogViewModel = viewModel(factory = LogViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
||||
val navigateUpAndClearMutual = {
|
||||
@@ -127,7 +129,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}
|
||||
@@ -138,6 +140,7 @@ private fun RadarScreen(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
@@ -194,11 +197,11 @@ private fun RadarScreen(
|
||||
}
|
||||
if (isVertical) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +211,7 @@ private fun RadarScreen(
|
||||
private fun PagerCard(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -218,6 +222,7 @@ private fun PagerCard(
|
||||
buildList {
|
||||
add(RadarPage.Transceivers)
|
||||
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||
add(RadarPage.Log)
|
||||
add(RadarPage.Sstv)
|
||||
}
|
||||
}
|
||||
@@ -261,6 +266,10 @@ private fun PagerCard(
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
RadarPage.Log -> LogPage(
|
||||
uiState = uiState,
|
||||
logViewModel = logViewModel
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||
|
||||
Reference in new issue
Block a user