feat: upload AMSAT status reports

This commit is contained in:
atsunatsu committed 2026-08-22 19:14:24 +08:00
1 parent cebe3b7ec4
commit 433ce85561
17 files changed
+560 -34

No files matched your search

@@ -1,5 +1,7 @@
package com.rtbishop.look4sat.core.data.repository package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatDay import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
@@ -12,6 +14,7 @@ import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -50,6 +53,46 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
SatStatusPage(System.currentTimeMillis(), statuses, reportMap) SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
} }
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("name", submission.name)
put("report", submission.report)
put("callsign", submission.callsign)
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
}
val response = remoteSource.submitAmSatReport(payload.toString())
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
val (code, body) = response
val errorMessage = parseSubmitError(body)
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
} else {
AmSatReportSubmitResult(
success = false,
message = errorMessage.ifBlank { "HTTP $code" }
)
}
}
private fun parseSubmitError(json: String): String {
return try {
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
} catch (_: Exception) {
""
}
}
private fun parseSubmitReportId(json: String): String? {
return try {
val obj = JSONObject(json)
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
?: obj.optString("id").takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
/** Parse catalog JSON to list of satellite names */ /** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> { private fun parseCatalog(json: String): List<String> {
return try { return try {
@@ -37,6 +37,8 @@ import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.json.JSONObject
import java.util.Locale
import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.domain.source.Sources
@@ -474,4 +476,55 @@ class SettingsRepo(
splitMode = preferences.getBoolean(keyRadioSplitMode, false) splitMode = preferences.getBoolean(keyRadioSplitMode, false)
) )
//endregion //endregion
//region # Per-satellite calculator offset settings
private val keySatelliteOffsets = "satelliteOffsets"
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val stored = try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
if (stored.isNotEmpty()) return stored
// Lazy migration from the fork's old UI-layer implementation, which stored
// one SharedPreferences entry per satellite directly from TransceiversPage.
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
val legacy = preferences.getString(legacyKey, "").orEmpty()
if (legacy.isNotEmpty()) {
setSatelliteOffset(catnum, legacy)
}
return legacy
}
override fun setSatelliteOffset(catnum: Int, offset: String) {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
obj.toString()
} catch (_: Exception) {
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
}
preferences.edit {
putString(keySatelliteOffsets, updated)
remove("$keyLegacySatelliteOffsetPrefix$catnum")
}
}
//endregion
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
override fun getAmSatCallsign(): String {
return preferences.getString(keyAmSatCallsign, "").orEmpty()
}
override fun setAmSatCallsign(callsign: String) {
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
}
//endregion
} }
@@ -22,8 +22,10 @@ import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream import java.io.InputStream
class RemoteSource( class RemoteSource(
@@ -90,4 +92,21 @@ class RemoteSource(
null null
} }
} }
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
try {
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php")
.header("User-Agent", "Look4Sat")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
response.code to response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource submitAmSatReport exception: $exception")
null
}
}
} }
@@ -126,6 +126,8 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
} }
private class FakeLocalSource : ILocalSource { private class FakeLocalSource : ILocalSource {
@@ -224,6 +226,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
} }
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
} }
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -0,0 +1,17 @@
package com.rtbishop.look4sat.core.domain.model
/** One public AMSAT satellite status report submission. */
data class AmSatReportSubmission(
val name: String,
val report: String,
val callsign: String,
val gridSquare: String,
val reportedAtUtcMillis: Long
)
/** Result of submitting an AMSAT satellite status report. */
data class AmSatReportSubmitResult(
val success: Boolean,
val message: String = "",
val reportId: String? = null
)
@@ -1,9 +1,14 @@
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatStatusPage import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */ /** AMSAT satellite status data source */
interface IAmSatRepository { interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */ /** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage? suspend fun fetchStatus(): SatStatusPage?
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
} }
@@ -77,4 +77,14 @@ interface ISettingsRepo {
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion //endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
} }
@@ -24,4 +24,5 @@ interface IRemoteSource {
suspend fun getNetworkStream(url: String): InputStream? suspend fun getNetworkStream(url: String): InputStream?
suspend fun getAmSatCatalog(): String? suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String? suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
} }
@@ -285,6 +285,7 @@ fun InfoDialog(
title: String, title: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAccept: () -> Unit, onAccept: () -> Unit,
extraAction: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
DialogShell(onDismissRequest = onDismiss) { padding -> DialogShell(onDismissRequest = onDismiss) { padding ->
@@ -303,6 +304,10 @@ fun InfoDialog(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
extraAction?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
} }
content() content()
@@ -53,6 +53,21 @@
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string> <string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
<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_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string>
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
<string name="amsat_upload_active">活跃</string>
<string name="amsat_upload_tlm">遥测/信标</string>
<string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">后退</string> <string name="radar_back">后退</string>
@@ -70,6 +70,21 @@
<string name="amsat_load_failed">Load failed - check network and retry</string> <string name="amsat_load_failed">Load failed - check network and retry</string>
<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_report">Upload report to AMSAT</string>
<string name="amsat_upload_status">Status</string>
<string name="amsat_upload_callsign">Callsign</string>
<string name="amsat_upload_grid">Grid</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_success">Report uploaded</string>
<string name="amsat_upload_failed">Upload failed: %s</string>
<string name="amsat_upload_callsign_required">Callsign is required</string>
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
<string name="amsat_upload_active">Active</string>
<string name="amsat_upload_tlm">TLM/Beacon</string>
<string name="amsat_upload_not_heard">Not Heard</string>
<string name="amsat_upload_crew_active">Crew Active</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
@@ -249,6 +249,7 @@ private fun PagerCard(
selectedUuid = uiState.transceivers.selectedUuid, selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos, orbitalPos = uiState.orbitalPos,
cw = uiState.cw, cw = uiState.cw,
calculatorOffsetKHz = uiState.calculatorOffsetKHz,
onAction = onAction, onAction = onAction,
requestMicPermission = requestMicPermission requestMicPermission = requestMicPermission
) )
@@ -69,7 +69,8 @@ data class RadarState(
val mutualEndTime: Long = 0L, val mutualEndTime: Long = 0L,
val mutualMaxElev: Double = 10.0, val mutualMaxElev: Double = 10.0,
val mutualLabelA: String = "你", val mutualLabelA: String = "你",
val mutualLabelB: String = "友台" val mutualLabelB: String = "友台",
val calculatorOffsetKHz: String = ""
) )
enum class SstvStatus { Idle, Recording } enum class SstvStatus { Idle, Recording }
@@ -124,4 +125,7 @@ sealed interface RadarAction {
data class CwSetToneFreq(val freq: Float) : RadarAction data class CwSetToneFreq(val freq: Float) : RadarAction
data class CwToggleExpanded(val expanded: Boolean) : RadarAction data class CwToggleExpanded(val expanded: Boolean) : RadarAction
data class CwPermissionResult(val granted: Boolean) : RadarAction data class CwPermissionResult(val granted: Boolean) : RadarAction
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
} }
@@ -151,7 +151,13 @@ class RadarViewModel(
transponders = allRadios.filter { it.downlinkLow != null } transponders = allRadios.filter { it.downlinkLow != null }
if (allRadios.isNotEmpty()) { if (allRadios.isNotEmpty()) {
val firstUuid = allRadios.first().uuid val firstUuid = allRadios.first().uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) } val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
calculatorOffsetKHz = offsetKHz
)
}
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) } transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
} }
if (!pass.isDeepSpace) { if (!pass.isDeepSpace) {
@@ -228,7 +234,16 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value // Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid val newUuid = if (isTogglingOff) null else action.uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) } val offsetKHz = if (!isTogglingOff) {
transponders.find { it.uuid == action.uuid }
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
} else ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = newUuid),
calculatorOffsetKHz = offsetKHz
)
}
// Only update the tracking service when selecting a different transponder to // Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand // avoid resetting a user-adjusted TX base on re-expand
if (!isTogglingOff) { if (!isTogglingOff) {
@@ -296,6 +311,15 @@ class RadarViewModel(
is RadarAction.CwToggleExpanded -> { is RadarAction.CwToggleExpanded -> {
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) } _uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
} }
is RadarAction.ChangeCalculatorOffset -> {
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
transponders.find { it.uuid == uuid }?.catnum
}
if (catnum != null) {
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
}
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
}
} }
} }
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.feature.radar package com.rtbishop.look4sat.feature.radar
import android.app.Activity import android.app.Activity
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
@@ -145,6 +144,7 @@ fun CalculatorPage(
selectedUuid: String?, selectedUuid: String?,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
cw: CwSubState, cw: CwSubState,
calculatorOffsetKHz: String = "",
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {}, requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -196,6 +196,8 @@ fun CalculatorPage(
DopplerFrequencyCalculator( DopplerFrequencyCalculator(
transponder = selectedTransceiver, transponder = selectedTransceiver,
orbitalPos = orbitalPos, orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -548,6 +550,8 @@ private enum class EditedField { TX, PASSBAND, RX }
private fun DopplerFrequencyCalculator( private fun DopplerFrequencyCalculator(
transponder: SatRadio, transponder: SatRadio,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
@@ -556,17 +560,6 @@ private fun DopplerFrequencyCalculator(
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) } var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) } var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) } var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
// 每个卫星独立记忆 offset:以 NORAD 编号为 key,存到应用 SharedPreferences
val context = LocalContext.current
val catnum = transponder.catnum
var offsetKHz by remember(catnum) {
mutableStateOf(
catnum?.let { cn ->
context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
.getString("offset_khz_$cn", "") ?: ""
} ?: ""
)
}
var stepSizeKHz by remember { mutableIntStateOf(1) } var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
@@ -752,15 +745,7 @@ private fun DopplerFrequencyCalculator(
) { ) {
BasicTextField( BasicTextField(
value = offsetKHz, value = offsetKHz,
onValueChange = { newValue -> onValueChange = onOffsetChange,
offsetKHz = newValue
catnum?.let { cn ->
context.getSharedPreferences(
context.packageName + "_preferences",
Context.MODE_PRIVATE
).edit().putString("offset_khz_$cn", newValue).apply()
}
},
singleLine = true, singleLine = true,
textStyle = TextStyle( textStyle = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
@@ -1,5 +1,6 @@
package com.rtbishop.look4sat.feature.status package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -20,9 +21,12 @@ 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -51,14 +55,28 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */ /** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp private val TILE_WIDTH: Dp = 64.dp
private data class UploadOption(val apiValue: String, val labelResId: Int)
private val UPLOAD_OPTIONS = listOf(
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active),
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm),
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard),
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active)
)
/** /**
* Map AMSAT status text to Material3 colorScheme colors. * Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
@@ -83,11 +101,29 @@ fun SatStatusDestination() {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SatStatusScreen(uiState) { viewModel.refresh() } SatStatusScreen(
uiState = uiState,
refresh = viewModel::refresh,
onToggleUpload = viewModel::toggleUploadPanel,
onUploadReportChange = viewModel::setUploadReport,
onUploadCallsignChange = viewModel::setUploadCallsign,
onUploadGridChange = viewModel::setUploadGrid,
onSubmitUpload = viewModel::submitReport,
onDismissUpload = viewModel::collapseUploadPanel
)
} }
@Composable @Composable
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) { private fun SatStatusScreen(
uiState: SatStatusUiState,
refresh: () -> Unit,
onToggleUpload: () -> Unit,
onUploadReportChange: (String) -> Unit,
onUploadCallsignChange: (String) -> Unit,
onUploadGridChange: (String) -> Unit,
onSubmitUpload: (String) -> Unit,
onDismissUpload: () -> Unit
) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) } var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) { Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
@@ -122,7 +158,21 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
} }
selectedDay?.let { (status, day) -> selectedDay?.let { (status, day) ->
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null }) ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
upload = uiState.upload,
onToggleUpload = onToggleUpload,
onReportChange = onUploadReportChange,
onCallsignChange = onUploadCallsignChange,
onGridChange = onUploadGridChange,
onSubmitReport = { onSubmitUpload(status.name) },
onDismiss = {
selectedDay = null
onDismissUpload()
}
)
} }
} }
@@ -278,11 +328,42 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
/** Report detail dialog (callsign / date / time / grid) */ /** Report detail dialog (callsign / date / time / grid) */
@Composable @Composable
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) { private fun ReportDialog(
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
upload: AmSatUploadUiState,
onToggleUpload: () -> Unit,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onSubmitReport: () -> Unit,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) { InfoDialog(
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
onAccept = onDismiss,
extraAction = {
CardButton(onClick = onToggleUpload, text = stringResource(R.string.amsat_upload))
}
) {
AnimatedVisibility(visible = upload.isExpanded) {
AmSatUploadPanel(
statusName = statusName,
upload = upload,
onReportChange = onReportChange,
onCallsignChange = onCallsignChange,
onGridChange = onGridChange,
onSubmitReport = onSubmitReport
)
}
if (dayReports.isEmpty()) { if (dayReports.isEmpty()) {
Text(stringResource(id = R.string.amsat_no_reports)) Text(
text = stringResource(id = R.string.amsat_no_reports),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} else { } else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
@@ -312,6 +393,110 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
} }
} }
@Composable
private fun AmSatUploadPanel(
statusName: String,
upload: AmSatUploadUiState,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onSubmitReport: () -> Unit
) {
val options = remember(statusName) {
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.amsat_upload_report),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.amsat_upload_status),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
options.forEach { option ->
FilterChip(
selected = upload.selectedReport == option.apiValue,
onClick = { onReportChange(option.apiValue) },
label = { Text(stringResource(option.labelResId), maxLines = 1) }
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = upload.callsign,
onValueChange = onCallsignChange,
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = upload.gridSquare,
onValueChange = onGridChange,
label = { Text(stringResource(R.string.amsat_upload_grid)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Button(
onClick = onSubmitReport,
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (upload.isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(R.string.amsat_upload_submit))
}
}
upload.message?.let { message ->
if (message == UPLOAD_MESSAGE_SUCCESS) {
Text(
text = stringResource(R.string.amsat_upload_success),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
upload.error?.let { error ->
Text(
text = uploadErrorText(error),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun uploadErrorText(error: String): String {
return when (error) {
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
else -> stringResource(R.string.amsat_upload_failed, error)
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String { private fun formatFetchedAt(utcMs: Long): String {
@@ -324,4 +509,11 @@ private fun formatFetchedAt(utcMs: Long): String {
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0') val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0') val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
return "$day$month $year - $hh:$mm:$ss" return "$day$month $year - $hh:$mm:$ss"
} }
private fun formatUtcNowForUpload(utcMs: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return formatter.format(Date(utcMs))
}
@@ -4,14 +4,36 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
const val AMSAT_REPORT_HEARD = "Heard"
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
const val UPLOAD_MESSAGE_SUCCESS = "success"
data class AmSatUploadUiState(
val isExpanded: Boolean = false,
val selectedReport: String = AMSAT_REPORT_HEARD,
val callsign: String = "",
val gridSquare: String = "",
val isSubmitting: Boolean = false,
val message: String? = null,
val error: String? = null
)
data class SatStatusUiState( data class SatStatusUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -19,11 +41,13 @@ data class SatStatusUiState(
val statuses: List<SatStatus> = emptyList(), val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(), val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L, val fetchedAtUtcMs: Long = 0L,
val error: String? = null val error: String? = null,
val upload: AmSatUploadUiState = AmSatUploadUiState()
) )
class SatStatusViewModel( class SatStatusViewModel(
private val amSatRepo: IAmSatRepository private val amSatRepo: IAmSatRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -85,11 +109,114 @@ class SatStatusViewModel(
} }
} }
fun toggleUploadPanel() {
val storedCallsign = settingsRepo.getAmSatCallsign()
val defaultGrid = defaultGridSquare()
_uiState.update { state ->
val upload = state.upload
val expanded = !upload.isExpanded
state.copy(
upload = if (expanded) {
upload.copy(
isExpanded = true,
callsign = upload.callsign.ifBlank { storedCallsign },
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
message = null,
error = null
)
} else {
upload.copy(isExpanded = false, message = null, error = null)
}
)
}
}
fun collapseUploadPanel() {
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, message = null, error = null)) }
}
fun setUploadReport(report: String) {
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, message = null, error = null)) }
}
fun setUploadCallsign(callsign: String) {
val normalized = callsign.trim().uppercase(Locale.US)
settingsRepo.setAmSatCallsign(normalized)
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, message = null, error = null)) }
}
fun setUploadGrid(grid: String) {
val normalized = grid.trim().uppercase(Locale.US)
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, message = null, error = null)) }
}
fun submitReport(satelliteName: String) {
val upload = _uiState.value.upload
if (upload.isSubmitting) return
val callsign = upload.callsign.trim().uppercase(Locale.US)
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)
_uiState.update { it.copy(upload = it.upload.copy(isSubmitting = true, error = null, message = null)) }
viewModelScope.launch {
val result = amSatRepo.submitReport(
AmSatReportSubmission(
name = satelliteName,
report = upload.selectedReport,
callsign = callsign,
gridSquare = grid,
reportedAtUtcMillis = System.currentTimeMillis()
)
)
if (result.success) {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
callsign = callsign,
gridSquare = grid,
message = UPLOAD_MESSAGE_SUCCESS,
error = null
)
)
}
refresh()
} else {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
message = null,
error = result.message.ifBlank { "Upload failed" }
)
)
}
}
}
}
private fun defaultGridSquare(): String {
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
}
companion object { companion object {
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
SatStatusViewModel(container.amSatRepo) SatStatusViewModel(container.amSatRepo, container.settingsRepo)
} }
} }
} }
} }
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")