mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-06 12:37:26 +00:00
merge: 合入 fork 9.2 发布线(AMSAT 冲突修复并入;版本保持 9.2/567)
# Conflicts: # gradle/libs.versions.toml
This commit is contained in:
commit
1fa7dd2d88
36 files changed
+1586
-32
No files matched your search
@@ -85,7 +85,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardCalculator
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardProgress
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
import com.rtbishop.look4sat.core.domain.model.firstCallsByGrid
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.myStationGrids
|
||||
import com.rtbishop.look4sat.core.domain.model.scopedToStation
|
||||
import com.rtbishop.look4sat.core.domain.model.stationGridSetKey
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -303,13 +307,6 @@ private fun MapScreen(
|
||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||
var selectedAward by mapFilterViewModel.selectedAward
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
|
||||
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
|
||||
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
}
|
||||
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
|
||||
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
|
||||
// location covers the same grid set belong to the same 台址; groups with an
|
||||
@@ -321,10 +318,8 @@ private fun MapScreen(
|
||||
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
|
||||
if (gs.isEmpty()) continue
|
||||
val key = gs.sorted().joinToString(",")
|
||||
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
|
||||
val key = q.stationGridSetKey() ?: continue
|
||||
byGridSet.getOrPut(key) { q.myStationGrids() to mutableSetOf() }.second.add(grid)
|
||||
}
|
||||
}
|
||||
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
|
||||
@@ -352,6 +347,13 @@ private fun MapScreen(
|
||||
if (selectedStationId == null) uiState.workedGrids
|
||||
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
|
||||
}
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode. Scoped to the selected 台址 like the fills: with a
|
||||
// specific operated grid chosen, the label must show ITS first contact, not
|
||||
// another 台址's (user decision 2026-10-05); "All" = global first call.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos, selectedStationId) {
|
||||
uiState.workedGridQsos.firstCallsByGrid(selectedStationId)
|
||||
}
|
||||
val isGridMode = uiState.isGridMode
|
||||
// True when this composition restored a saved viewport. Only grid mode
|
||||
// restores the viewport the user left (satellite mode keeps its original
|
||||
@@ -539,7 +541,11 @@ private fun MapScreen(
|
||||
selectedGrid?.let { grid ->
|
||||
WorkedGridQsoDialog(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
// Scope to the selected 台址 (null = All): a specific station location
|
||||
// must not show other locations' QSOs (bug report 2026-10-05).
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty()
|
||||
.scopedToStation(selectedStationId)
|
||||
.sortedBy { it.epochMs },
|
||||
marked = uiState.markedGrids[grid].orEmpty(),
|
||||
isUtc = uiState.isUtc,
|
||||
matchCalculating = matchCalculating,
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
@@ -107,6 +108,8 @@ class FakeSettingsRepo(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
|
||||
@@ -260,6 +260,7 @@ fun LogPage(
|
||||
UploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
wavelogCount = logUiState.wavelogPending?.count ?: 0,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
)
|
||||
@@ -406,6 +407,7 @@ private fun PostDialog(
|
||||
private fun UploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
wavelogCount: Int = 0,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -454,6 +456,13 @@ private fun UploadPreviewDialog(
|
||||
}
|
||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (wavelogCount > 0) {
|
||||
Text(
|
||||
"同时同步 Wavelog:$wavelogCount 条",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,13 +21,21 @@ import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
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.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -78,6 +86,9 @@ data class LogUiState(
|
||||
val message: String = "",
|
||||
val postText: String? = null,
|
||||
val busy: Boolean = false,
|
||||
/** Wavelog piggyback: batch prepared alongside the LoTW preview (see prepareUpload);
|
||||
* uploaded right after a confirmed LoTW upload. Null when Wavelog is not ready. */
|
||||
val wavelogPending: WavelogUploadPreview? = null,
|
||||
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
|
||||
val outOfWindowLog: OutOfWindowLog? = null
|
||||
)
|
||||
@@ -85,6 +96,7 @@ data class LogUiState(
|
||||
class LogViewModel(
|
||||
private val qsoRepository: IQsoRepository,
|
||||
private val lotwUploadRepository: ILoTWUploadRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -260,6 +272,16 @@ class LogViewModel(
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
// Wavelog piggyback (option A): prepare its batch while we are here, so
|
||||
// the LoTW preview can show the count and confirm can post it directly.
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
val wavelogPreview = if (wavelogSettings.isReady) {
|
||||
runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), wavelogSettings)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (audit.pending == 0) {
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> unavailableUploadSummary(
|
||||
@@ -269,7 +291,14 @@ class LogViewModel(
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, message = msg) }
|
||||
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
|
||||
// freshly configured Wavelog syncs without waiting for new QSOs.
|
||||
val wavelogMsg = wavelogPreview?.let {
|
||||
if (it.count > 0) uploadWavelogNow(it, wavelogSettings) else wavelogIdleSegment(it)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(busy = false, message = listOfNotNull(msg, wavelogMsg).joinToString("\n"))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
@@ -280,6 +309,7 @@ class LogViewModel(
|
||||
it.copy(
|
||||
busy = false,
|
||||
preview = preview,
|
||||
wavelogPending = wavelogPreview,
|
||||
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
@@ -308,11 +338,40 @@ class LogViewModel(
|
||||
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) }
|
||||
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
|
||||
// outcome — a Wavelog failure leaves records unmarked for the next upload.
|
||||
val wavelogPending = _uiState.value.wavelogPending
|
||||
val wavelogMsg = wavelogPending?.let { wl ->
|
||||
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
|
||||
else wavelogIdleSegment(wl)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
busy = false, preview = null, wavelogPending = null,
|
||||
message = listOfNotNull(msg, wavelogMsg).joinToString("\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||
* accepted. Returns the operator-facing message segment for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
settings: WavelogUploadSettings
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
wavelogTransportFailureSegment()
|
||||
}
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
|
||||
@@ -321,7 +380,7 @@ class LogViewModel(
|
||||
fun abandonForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastUploadedIds = emptyList()
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null, wavelogPending = null) }
|
||||
}
|
||||
|
||||
/** Recompute the log-page roaming hint from the freshest station/position data. */
|
||||
@@ -347,7 +406,10 @@ class LogViewModel(
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
|
||||
LogViewModel(
|
||||
container.qsoRepository, container.lotwUploadRepository,
|
||||
container.wavelogUploadRepository, container.settingsRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -104,7 +104,9 @@ fun LogbookDialog(
|
||||
onStartSelection: (Long) -> Unit = {},
|
||||
onToggleSelection: (Long) -> Unit = {},
|
||||
onExitSelection: () -> Unit = {},
|
||||
onResubmitSelected: () -> Unit = {}
|
||||
onResubmitSelected: () -> Unit = {},
|
||||
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
|
||||
wavelogCount: Int = 0
|
||||
) {
|
||||
val swipeController = rememberSwipeController()
|
||||
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||
@@ -181,6 +183,7 @@ fun LogbookDialog(
|
||||
LogbookUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = uploadBusy,
|
||||
wavelogCount = wavelogCount,
|
||||
onConfirm = onConfirmUpload,
|
||||
onDismiss = onDismissPreview
|
||||
)
|
||||
@@ -215,6 +218,7 @@ fun LogbookDialog(
|
||||
private fun LogbookUploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
wavelogCount: Int,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -255,6 +259,13 @@ private fun LogbookUploadPreviewDialog(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (wavelogCount > 0) {
|
||||
Text(
|
||||
stringResource(R.string.prefs_logbook_wavelog_sync, wavelogCount),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
|
||||
+111
@@ -225,6 +225,117 @@ fun WavelogDialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WavelogUploadDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
|
||||
rights: String,
|
||||
probeBusy: Boolean,
|
||||
message: String?,
|
||||
dismiss: () -> Unit,
|
||||
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) -> Unit,
|
||||
onFetchStations: (url: String, apiKey: String) -> Unit,
|
||||
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit
|
||||
) {
|
||||
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
|
||||
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_wavelog_upload_title),
|
||||
onCancel = dismiss,
|
||||
onAccept = {
|
||||
onSave(initialSettings.copy(url = url.value, apiKey = apiKey.value))
|
||||
dismiss()
|
||||
}
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = url.value,
|
||||
onValueChange = { url.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
|
||||
placeholder = { Text(text = "http://100.x.x.x/wavelog") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = apiKey.value,
|
||||
onValueChange = { apiKey.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_upload_apikey)) },
|
||||
placeholder = { Text(text = "wl…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
TextButton(onClick = { onFetchStations(url.value, apiKey.value) }, enabled = !probeBusy) {
|
||||
Text(
|
||||
text = if (probeBusy) stringResource(R.string.prefs_wavelog_upload_probing)
|
||||
else stringResource(R.string.prefs_wavelog_upload_fetch)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rights.isNotBlank()) {
|
||||
Text(
|
||||
text = when (rights) {
|
||||
"rw" -> stringResource(R.string.prefs_wavelog_upload_rights_rw)
|
||||
else -> stringResource(R.string.prefs_wavelog_upload_rights_r)
|
||||
},
|
||||
color = if (rights == "rw") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (stations.isEmpty() && initialSettings.stationId.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_upload_station_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
} else {
|
||||
stations.forEach { station ->
|
||||
val selected = station.id == initialSettings.stationId
|
||||
Text(
|
||||
text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · "),
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectStation(station) }
|
||||
.padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (initialSettings.stationId.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.prefs_wavelog_upload_configured,
|
||||
listOf(initialSettings.stationName, initialSettings.stationCallsign, initialSettings.stationGrid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_upload_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoTWDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
|
||||
+60
-2
@@ -318,7 +318,8 @@ private fun SettingsScreen(
|
||||
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
|
||||
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
|
||||
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) }
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
|
||||
wavelogCount = uiState.wavelogPending?.count ?: 0
|
||||
)
|
||||
}
|
||||
if (dialogs.lotwUpload) {
|
||||
@@ -340,6 +341,20 @@ private fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (dialogs.wavelogUpload) {
|
||||
WavelogUploadDialog(
|
||||
initialSettings = uiState.wavelogUploadSettings,
|
||||
stations = uiState.wavelogUploadStations,
|
||||
rights = uiState.wavelogUploadRights,
|
||||
probeBusy = uiState.wavelogUploadProbeBusy,
|
||||
message = uiState.wavelogUploadMessage,
|
||||
dismiss = { dialogs.wavelogUpload = false },
|
||||
onSave = { onAction(SettingsAction.UpdateWavelogUpload(it)) },
|
||||
onFetchStations = { url, apiKey -> onAction(SettingsAction.FetchWavelogUploadStations(url, apiKey)) },
|
||||
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
// URLs for top bar
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
|
||||
@@ -461,6 +476,12 @@ private fun SettingsScreen(
|
||||
showLoTWDialog = { dialogs.lotw = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
WavelogUploadCard(
|
||||
settings = uiState.wavelogUploadSettings,
|
||||
showDialog = { dialogs.wavelogUpload = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
OtherCard(
|
||||
settings = uiState.otherSettings,
|
||||
@@ -883,6 +904,41 @@ private fun LoTWCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WavelogUploadCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||
showDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_wavelog_upload_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (settings.isReady) {
|
||||
stringResource(
|
||||
R.string.prefs_wavelog_upload_configured,
|
||||
listOf(settings.stationName, settings.stationCallsign, settings.stationGrid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.prefs_wavelog_upload_not_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showDialog,
|
||||
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatUpdateTime(updateTime: Long): String {
|
||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||
@@ -1029,6 +1085,7 @@ private class DialogVisibility {
|
||||
var compassCalibration by mutableStateOf(false)
|
||||
var logbook by mutableStateOf(false)
|
||||
var lotwUpload by mutableStateOf(false)
|
||||
var wavelogUpload by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1036,7 +1093,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
return rememberSaveable(saver = run {
|
||||
androidx.compose.runtime.saveable.Saver(
|
||||
save = {
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload, it.wavelogUpload)
|
||||
},
|
||||
restore = {
|
||||
DialogVisibility().apply {
|
||||
@@ -1046,6 +1103,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
compassCalibration = it.getOrElse(8) { false }
|
||||
logbook = it.getOrElse(9) { false }
|
||||
lotwUpload = it.getOrElse(10) { false }
|
||||
wavelogUpload = it.getOrElse(11) { false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+16
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
@@ -61,6 +62,16 @@ data class SettingsState(
|
||||
val workedGridsCount: Int = 0,
|
||||
val wavelogSyncing: Boolean = false,
|
||||
val wavelogMessage: String? = null,
|
||||
/** Wavelog upload: its own config block + probe/upload transient state. */
|
||||
val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
|
||||
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
|
||||
val wavelogUploadRights: String = "",
|
||||
val wavelogUploadProbeBusy: Boolean = false,
|
||||
/** Wavelog batch prepared alongside the logbook LoTW preview; uploaded when the
|
||||
* LoTW upload is confirmed (null when Wavelog is not ready). */
|
||||
val wavelogPending: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
|
||||
/** Config-block feedback (probe failures etc.); also shown inside the config dialog. */
|
||||
val wavelogUploadMessage: String? = null,
|
||||
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
|
||||
val lotwSyncing: Boolean = false,
|
||||
val lotwSyncMode: LoTWSyncMode? = null,
|
||||
@@ -150,6 +161,11 @@ sealed interface SettingsAction {
|
||||
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
|
||||
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
|
||||
|
||||
// Wavelog upload (separate config block + upload flow)
|
||||
data class UpdateWavelogUpload(val settings: WavelogUploadSettings) : SettingsAction
|
||||
data class FetchWavelogUploadStations(val url: String, val apiKey: String) : SettingsAction
|
||||
data class SelectWavelogUploadStation(val station: com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) : SettingsAction
|
||||
|
||||
// LoTW confirmed grids
|
||||
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
||||
data class SyncLoTWGrids(
|
||||
|
||||
+124
-5
@@ -24,6 +24,7 @@ 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.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
@@ -36,11 +37,19 @@ import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -54,6 +63,7 @@ class SettingsViewModel(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val wavelogRepo: IWavelogRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
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,
|
||||
@@ -79,6 +89,7 @@ class SettingsViewModel(
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
compassAccuracy = sensorsRepo.compassAccuracy.value,
|
||||
compassHeadingDegrees = correctedCompassHeading(),
|
||||
@@ -156,6 +167,11 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(wavelogSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogUploadSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.lotwSettings.collect { settings ->
|
||||
_uiState.update { it.copy(lotwSettings = settings) }
|
||||
@@ -225,6 +241,10 @@ class SettingsViewModel(
|
||||
// Wavelog worked grids
|
||||
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
|
||||
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
|
||||
// Wavelog upload config (the upload itself rides the LoTW logbook flow)
|
||||
is SettingsAction.UpdateWavelogUpload -> settingsRepo.updateWavelogUploadSettings(action.settings)
|
||||
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
|
||||
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
|
||||
// LoTW confirmed grids
|
||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||
@@ -280,6 +300,78 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Wavelog upload (separate config block + upload flow)
|
||||
|
||||
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
|
||||
if (_uiState.value.wavelogUploadProbeBusy) return
|
||||
_uiState.update { it.copy(wavelogUploadProbeBusy = true, wavelogUploadMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val probe = wavelogUploadRepository.probe(url.trim(), apiKey.trim())
|
||||
if (probe == null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogUploadProbeBusy = false,
|
||||
wavelogUploadMessage = "Wavelog probe failed — check URL/key/network"
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogUploadProbeBusy = false,
|
||||
wavelogUploadStations = probe.stations,
|
||||
wavelogUploadRights = probe.rights
|
||||
)
|
||||
}
|
||||
// Single-station setups select themselves; a saved station that the server
|
||||
// still reports stays selected.
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
val savedStillPresent = current.stationId.isNotBlank() && probe.stations.any { it.id == current.stationId }
|
||||
val auto = if (probe.stations.size == 1 && !savedStillPresent) probe.stations.single() else null
|
||||
if (auto != null) selectWavelogUploadStation(auto)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectWavelogUploadStation(station: WavelogStationInfo) {
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
settingsRepo.updateWavelogUploadSettings(
|
||||
current.copy(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
stationCallsign = station.callsign,
|
||||
stationGrid = station.grid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Prepares the Wavelog batch riding the LoTW upload (null when Wavelog is not ready). */
|
||||
private suspend fun prepareWavelogPiggyback(
|
||||
records: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>
|
||||
): WavelogUploadPreview? {
|
||||
val settings = settingsRepo.wavelogUploadSettings.value
|
||||
if (!settings.isReady) return null
|
||||
return runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(records), settings)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||
* accepted. Returns the operator-facing message segment for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
settings: WavelogUploadSettings
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
wavelogTransportFailureSegment()
|
||||
}
|
||||
|
||||
private fun syncLoTWGrids(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
mode: LoTWSyncMode
|
||||
@@ -389,6 +481,9 @@ class SettingsViewModel(
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
// Wavelog piggyback (option A): prepare its batch while we are here, so
|
||||
// the logbook preview can show the count and confirm can post it directly.
|
||||
val wavelogPreview = prepareWavelogPiggyback(all)
|
||||
if (audit.pending == 0) {
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
||||
@@ -398,7 +493,17 @@ class SettingsViewModel(
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
|
||||
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
|
||||
// freshly configured Wavelog syncs without waiting for new QSOs.
|
||||
val wavelogMsg = wavelogPreview?.let {
|
||||
if (it.count > 0) uploadWavelogNow(it, settingsRepo.wavelogUploadSettings.value) else wavelogIdleSegment(it)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n")
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
@@ -409,6 +514,7 @@ class SettingsViewModel(
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = preview,
|
||||
wavelogPending = wavelogPreview,
|
||||
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
@@ -436,11 +542,19 @@ class SettingsViewModel(
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||
}
|
||||
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
|
||||
// outcome — a Wavelog failure leaves records unmarked for the next upload.
|
||||
val wavelogPending = _uiState.value.wavelogPending
|
||||
val wavelogMsg = wavelogPending?.let { wl ->
|
||||
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
|
||||
else wavelogIdleSegment(wl)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = null,
|
||||
logbookUploadMessage = msg,
|
||||
wavelogPending = null,
|
||||
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n"),
|
||||
// An accepted resubmit is done — drop the checks; a failed one keeps
|
||||
// them so retrying stays one tap away.
|
||||
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
|
||||
@@ -450,7 +564,7 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||
@@ -459,7 +573,7 @@ class SettingsViewModel(
|
||||
private fun abandonLogbookForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastLogbookUploadIds = emptyList()
|
||||
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
|
||||
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
|
||||
}
|
||||
|
||||
/** Long-press entry: selection mode with the pressed record checked. */
|
||||
@@ -486,7 +600,8 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
try {
|
||||
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds)
|
||||
val all = qsoRepository.records.first()
|
||||
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
|
||||
if (selected.isEmpty()) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||
return@launch
|
||||
@@ -502,10 +617,13 @@ class SettingsViewModel(
|
||||
return@launch
|
||||
}
|
||||
lastLogbookUploadIds = preview.submittedIds
|
||||
// Wavelog piggyback: the resubmit confirmation syncs the backlog too.
|
||||
val wavelogPending = prepareWavelogPiggyback(all)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = preview,
|
||||
wavelogPending = wavelogPending,
|
||||
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
@@ -704,6 +822,7 @@ class SettingsViewModel(
|
||||
settingsRepo = container.settingsRepo,
|
||||
updateRepo = container.updateRepo,
|
||||
wavelogRepo = container.wavelogRepo,
|
||||
wavelogUploadRepository = container.wavelogUploadRepository,
|
||||
lotwRepo = container.lotwRepo,
|
||||
qsoRepository = container.qsoRepository,
|
||||
lotwUploadRepository = container.lotwUploadRepository,
|
||||
|
||||
Reference in new issue
Block a user