feat: confirm AMSAT report uploads

This commit is contained in:
atsunatsu committed 2026-08-22 20:50:39 +08:00
1 parent 1d54ac4718
commit ee32c7a1eb
4 files changed
+162 -33

No files matched your search

@@ -54,12 +54,17 @@
<string name="amsat_no_reports">此时间段无报告</string> <string name="amsat_no_reports">此时间段无报告</string>
<string name="amsat_close">关闭</string> <string name="amsat_close">关闭</string>
<string name="amsat_upload">上传</string> <string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string> <string name="amsat_upload_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string> <string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string> <string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string> <string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string> <string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string> <string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_confirm">确认上传</string>
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
<string name="amsat_upload_grid_none">未填写</string>
<string name="amsat_upload_success">报告已上传</string> <string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string> <string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string> <string name="amsat_upload_callsign_required">请填写呼号</string>
@@ -71,12 +71,17 @@
<string name="amsat_no_reports">No reports for this period</string> <string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string> <string name="amsat_close">Close</string>
<string name="amsat_upload">Upload</string> <string name="amsat_upload">Upload</string>
<string name="amsat_upload_back">Back</string>
<string name="amsat_upload_report">Upload report to AMSAT</string> <string name="amsat_upload_report">Upload report to AMSAT</string>
<string name="amsat_upload_status">Status</string> <string name="amsat_upload_status">Status</string>
<string name="amsat_upload_callsign">Callsign</string> <string name="amsat_upload_callsign">Callsign</string>
<string name="amsat_upload_grid">Grid</string> <string name="amsat_upload_grid">Grid</string>
<string name="amsat_upload_time_now">Time: current UTC %s</string> <string name="amsat_upload_time_now">Time: current UTC %s</string>
<string name="amsat_upload_submit">Submit to AMSAT</string> <string name="amsat_upload_submit">Submit to AMSAT</string>
<string name="amsat_upload_confirm">Upload</string>
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
<string name="amsat_upload_grid_none">Not provided</string>
<string name="amsat_upload_success">Report uploaded</string> <string name="amsat_upload_success">Report uploaded</string>
<string name="amsat_upload_failed">Upload failed: %s</string> <string name="amsat_upload_failed">Upload failed: %s</string>
<string name="amsat_upload_callsign_required">Callsign is required</string> <string name="amsat_upload_callsign_required">Callsign is required</string>
@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@@ -113,7 +114,9 @@ fun SatStatusDestination() {
onUploadReportChange = viewModel::setUploadReport, onUploadReportChange = viewModel::setUploadReport,
onUploadCallsignChange = viewModel::setUploadCallsign, onUploadCallsignChange = viewModel::setUploadCallsign,
onUploadGridChange = viewModel::setUploadGrid, onUploadGridChange = viewModel::setUploadGrid,
onSubmitUpload = viewModel::submitReport, onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
onDismissUpload = viewModel::collapseUploadPanel onDismissUpload = viewModel::collapseUploadPanel
) )
} }
@@ -126,7 +129,9 @@ private fun SatStatusScreen(
onUploadReportChange: (String) -> Unit, onUploadReportChange: (String) -> Unit,
onUploadCallsignChange: (String) -> Unit, onUploadCallsignChange: (String) -> Unit,
onUploadGridChange: (String) -> Unit, onUploadGridChange: (String) -> Unit,
onSubmitUpload: (String) -> Unit, onRequestSubmitUpload: () -> Unit,
onConfirmSubmitUpload: (String) -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismissUpload: () -> Unit onDismissUpload: () -> Unit
) { ) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) } var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
@@ -172,7 +177,9 @@ private fun SatStatusScreen(
onReportChange = onUploadReportChange, onReportChange = onUploadReportChange,
onCallsignChange = onUploadCallsignChange, onCallsignChange = onUploadCallsignChange,
onGridChange = onUploadGridChange, onGridChange = onUploadGridChange,
onSubmitReport = { onSubmitUpload(status.name) }, onRequestSubmitReport = onRequestSubmitUpload,
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
onDismiss = { onDismiss = {
selectedDay = null selectedDay = null
onDismissUpload() onDismissUpload()
@@ -342,7 +349,9 @@ private fun ReportDialog(
onReportChange: (String) -> Unit, onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit, onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit, onGridChange: (String) -> Unit,
onSubmitReport: () -> Unit, onRequestSubmitReport: () -> Unit,
onConfirmSubmitReport: () -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
@@ -351,7 +360,10 @@ private fun ReportDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
onAccept = onDismiss, onAccept = onDismiss,
extraAction = { extraAction = {
CardButton(onClick = onToggleUpload, text = stringResource(R.string.amsat_upload)) CardButton(
onClick = onToggleUpload,
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
)
} }
) { ) {
AnimatedVisibility(visible = upload.isExpanded) { AnimatedVisibility(visible = upload.isExpanded) {
@@ -361,7 +373,15 @@ private fun ReportDialog(
onReportChange = onReportChange, onReportChange = onReportChange,
onCallsignChange = onCallsignChange, onCallsignChange = onCallsignChange,
onGridChange = onGridChange, onGridChange = onGridChange,
onSubmitReport = onSubmitReport onRequestSubmitReport = onRequestSubmitReport
)
}
if (upload.isConfirmingSubmit) {
AmSatUploadConfirmDialog(
statusName = statusName,
upload = upload,
onConfirm = onConfirmSubmitReport,
onDismiss = onDismissSubmitConfirmation
) )
} }
if (dayReports.isEmpty()) { if (dayReports.isEmpty()) {
@@ -405,7 +425,7 @@ private fun AmSatUploadPanel(
onReportChange: (String) -> Unit, onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit, onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit, onGridChange: (String) -> Unit,
onSubmitReport: () -> Unit onRequestSubmitReport: () -> Unit
) { ) {
val options = remember(statusName) { val options = remember(statusName) {
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE } if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
@@ -488,7 +508,7 @@ private fun AmSatUploadPanel(
) )
} }
Button( Button(
onClick = onSubmitReport, onClick = onRequestSubmitReport,
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(), enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
@@ -517,6 +537,43 @@ private fun AmSatUploadPanel(
} }
} }
@Composable
private fun AmSatUploadConfirmDialog(
statusName: String,
upload: AmSatUploadUiState,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
val statusLabel = stringResource(option.labelResId)
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
text = {
Text(
text = stringResource(
R.string.amsat_upload_confirm_message,
statusName,
statusLabel,
upload.callsign,
grid
)
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.amsat_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
@Composable @Composable
private fun uploadErrorText(error: String): String { private fun uploadErrorText(error: String): String {
return when (error) { return when (error) {
@@ -31,6 +31,7 @@ data class AmSatUploadUiState(
val callsign: String = "", val callsign: String = "",
val gridSquare: String = "", val gridSquare: String = "",
val isSubmitting: Boolean = false, val isSubmitting: Boolean = false,
val isConfirmingSubmit: Boolean = false,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )
@@ -125,56 +126,75 @@ class SatStatusViewModel(
error = null error = null
) )
} else { } else {
upload.copy(isExpanded = false, message = null, error = null) upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
} }
) )
} }
} }
fun collapseUploadPanel() { fun collapseUploadPanel() {
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, message = null, error = null)) } _uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)) }
} }
fun setUploadReport(report: String) { fun setUploadReport(report: String) {
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, message = null, error = null)) } _uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null)) }
} }
fun setUploadCallsign(callsign: String) { fun setUploadCallsign(callsign: String) {
val normalized = callsign.trim().uppercase(Locale.US) val normalized = callsign.trim().uppercase(Locale.US)
settingsRepo.setAmSatCallsign(normalized) settingsRepo.setAmSatCallsign(normalized)
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, message = null, error = null)) } _uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null)) }
} }
fun setUploadGrid(grid: String) { fun setUploadGrid(grid: String) {
val normalized = grid.trim().uppercase(Locale.US) val normalized = grid.trim().uppercase(Locale.US)
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, message = null, error = null)) } _uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null)) }
} }
fun submitReport(satelliteName: String) { fun requestSubmitConfirmation() {
val prepared = validateUpload(_uiState.value.upload) ?: return
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isConfirmingSubmit = true,
message = null,
error = null
)
)
}
}
fun dismissSubmitConfirmation() {
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
}
fun confirmSubmitReport(satelliteName: String) {
val upload = _uiState.value.upload val upload = _uiState.value.upload
if (upload.isSubmitting) return if (upload.isSubmitting) return
val callsign = upload.callsign.trim().uppercase(Locale.US) val prepared = validateUpload(upload) ?: return
val grid = upload.gridSquare.trim().uppercase(Locale.US)
when {
callsign.isBlank() -> {
_uiState.update { it.copy(upload = it.upload.copy(error = UPLOAD_ERROR_CALLSIGN_REQUIRED, message = null)) }
return
}
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
_uiState.update { it.copy(upload = it.upload.copy(error = UPLOAD_ERROR_GRID_INVALID, message = null)) }
return
}
}
settingsRepo.setAmSatCallsign(callsign) settingsRepo.setAmSatCallsign(prepared.callsign)
_uiState.update { it.copy(upload = it.upload.copy(isSubmitting = true, error = null, message = null)) } _uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isSubmitting = true,
isConfirmingSubmit = false,
error = null,
message = null
)
)
}
viewModelScope.launch { viewModelScope.launch {
val result = amSatRepo.submitReport( val result = amSatRepo.submitReport(
AmSatReportSubmission( AmSatReportSubmission(
name = satelliteName, name = satelliteName,
report = upload.selectedReport, report = prepared.report,
callsign = callsign, callsign = prepared.callsign,
gridSquare = grid, gridSquare = prepared.grid,
reportedAtUtcMillis = System.currentTimeMillis() reportedAtUtcMillis = System.currentTimeMillis()
) )
) )
@@ -183,8 +203,8 @@ class SatStatusViewModel(
it.copy( it.copy(
upload = it.upload.copy( upload = it.upload.copy(
isSubmitting = false, isSubmitting = false,
callsign = callsign, callsign = prepared.callsign,
gridSquare = grid, gridSquare = prepared.grid,
message = UPLOAD_MESSAGE_SUCCESS, message = UPLOAD_MESSAGE_SUCCESS,
error = null error = null
) )
@@ -205,6 +225,42 @@ class SatStatusViewModel(
} }
} }
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
val callsign = upload.callsign.trim().uppercase(Locale.US)
val grid = upload.gridSquare.trim().uppercase(Locale.US)
return when {
callsign.isBlank() -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
message = null
)
)
}
null
}
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_GRID_INVALID,
message = null
)
)
}
null
}
else -> PreparedUpload(
report = upload.selectedReport,
callsign = callsign,
grid = grid
)
}
}
private fun defaultGridSquare(): String { private fun defaultGridSquare(): String {
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US) val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
return grid.takeUnless { it == "JJ00AA" }.orEmpty() return grid.takeUnless { it == "JJ00AA" }.orEmpty()
@@ -219,4 +275,10 @@ class SatStatusViewModel(
} }
} }
private data class PreparedUpload(
val report: String,
val callsign: String,
val grid: String
)
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$") private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")