From 433ce85561188025eaeaae8612c14b13d212216a Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 22 Aug 2026 19:14:24 +0800 Subject: [PATCH] feat: upload AMSAT status reports --- .../core/data/repository/AmSatRepository.kt | 43 ++++ .../core/data/repository/SettingsRepo.kt | 53 +++++ .../look4sat/core/data/source/RemoteSource.kt | 19 ++ .../core/data/repository/DatabaseRepoTest.kt | 10 + .../domain/model/AmSatReportSubmission.kt | 17 ++ .../domain/repository/IAmSatRepository.kt | 5 + .../core/domain/repository/ISettingsRepo.kt | 10 + .../core/domain/source/IRemoteSource.kt | 1 + .../look4sat/core/presentation/Components.kt | 5 + .../src/main/res/values-zh/strings.xml | 15 ++ .../src/main/res/values/strings.xml | 15 ++ .../look4sat/feature/radar/RadarScreen.kt | 1 + .../look4sat/feature/radar/RadarState.kt | 6 +- .../look4sat/feature/radar/RadarViewModel.kt | 28 ++- .../feature/radar/TransceiversPage.kt | 27 +-- .../feature/status/SatStatusScreen.kt | 206 +++++++++++++++++- .../feature/status/SatStatusViewModel.kt | 133 ++++++++++- 17 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/AmSatReportSubmission.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index 62c26393..ab954796 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -1,5 +1,7 @@ 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.SatReport import com.rtbishop.look4sat.core.domain.model.SatSlot @@ -12,6 +14,7 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import java.text.SimpleDateFormat import java.util.Calendar +import java.util.Date import java.util.Locale import java.util.TimeZone @@ -50,6 +53,46 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor 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 */ private fun parseCatalog(json: String): List { return try { diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 7e0fe38d..787a841b 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -37,6 +37,8 @@ import com.rtbishop.look4sat.core.domain.utility.round import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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.source.Sources @@ -474,4 +476,55 @@ class SettingsRepo( splitMode = preferences.getBoolean(keyRadioSplitMode, false) ) //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 } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index 29684304..564907fc 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -22,8 +22,10 @@ import androidx.core.net.toUri import com.rtbishop.look4sat.core.domain.source.IRemoteSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.io.InputStream class RemoteSource( @@ -90,4 +92,21 @@ class RemoteSource( null } } + + override suspend fun submitAmSatReport(payloadJson: String): Pair? = 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 + } + } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index c7cae436..c5837e4f 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -126,6 +126,8 @@ private class FakeRemoteSource : IRemoteSource { override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null + + override suspend fun submitAmSatReport(payloadJson: String): Pair? = null } private class FakeLocalSource : ILocalSource { @@ -224,6 +226,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou } 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 { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/AmSatReportSubmission.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/AmSatReportSubmission.kt new file mode 100644 index 00000000..ca7edce5 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/AmSatReportSubmission.kt @@ -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 +) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt index 71d37add..847e072d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt @@ -1,9 +1,14 @@ 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 /** AMSAT satellite status data source */ interface IAmSatRepository { /** Fetch and parse the AMSAT status page; null on failure */ suspend fun fetchStatus(): SatStatusPage? + + /** Submit a public AMSAT satellite status report. */ + suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 23b726e2..860256ca 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -77,4 +77,14 @@ interface ISettingsRepo { val radioControlSettings: StateFlow fun updateRadioControlSettings(settings: RadioControlSettings) //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 } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index 6e1ccfff..7b09d8b5 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -24,4 +24,5 @@ interface IRemoteSource { suspend fun getNetworkStream(url: String): InputStream? suspend fun getAmSatCatalog(): String? suspend fun getAmSatReports(hours: Int, limit: Int): String? + suspend fun submitAmSatReport(payloadJson: String): Pair? } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index 0a19ee53..8918cbc5 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -285,6 +285,7 @@ fun InfoDialog( title: String, onDismiss: () -> Unit, onAccept: () -> Unit, + extraAction: (@Composable () -> Unit)? = null, content: @Composable () -> Unit ) { DialogShell(onDismissRequest = onDismiss) { padding -> @@ -303,6 +304,10 @@ fun InfoDialog( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) + extraAction?.let { + it() + Spacer(modifier = Modifier.width(8.dp)) + } CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) } content() diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 139032e0..e4d99acc 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -53,6 +53,21 @@ 加载失败 - 检查网络后重试 此时间段无报告 关闭 + 上传 + 上传报告到 AMSAT + 状态 + 呼号 + 网格 + 时间:当前 UTC %s + 提交到 AMSAT + 报告已上传 + 上传失败:%s + 请填写呼号 + 网格应为 4 或 6 位 Maidenhead 定位符 + 活跃 + 遥测/信标 + 未听到 + ISS 语音 后退 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index c8615ad6..4ab064c8 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -70,6 +70,21 @@ Load failed - check network and retry No reports for this period Close + Upload + Upload report to AMSAT + Status + Callsign + Grid + Time: current UTC %s + Submit to AMSAT + Report uploaded + Upload failed: %s + Callsign is required + Grid must be a 4 or 6 character Maidenhead locator + Active + TLM/Beacon + Not Heard + Crew Active Back diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 26cf1d6d..b0d3281b 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -249,6 +249,7 @@ private fun PagerCard( selectedUuid = uiState.transceivers.selectedUuid, orbitalPos = uiState.orbitalPos, cw = uiState.cw, + calculatorOffsetKHz = uiState.calculatorOffsetKHz, onAction = onAction, requestMicPermission = requestMicPermission ) diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index 5f747026..48a87ad0 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -69,7 +69,8 @@ data class RadarState( val mutualEndTime: Long = 0L, val mutualMaxElev: Double = 10.0, val mutualLabelA: String = "你", - val mutualLabelB: String = "友台" + val mutualLabelB: String = "友台", + val calculatorOffsetKHz: String = "" ) enum class SstvStatus { Idle, Recording } @@ -124,4 +125,7 @@ sealed interface RadarAction { data class CwSetToneFreq(val freq: Float) : RadarAction data class CwToggleExpanded(val expanded: Boolean) : RadarAction data class CwPermissionResult(val granted: Boolean) : RadarAction + + // Calculator actions + data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index c3a84532..6f84be11 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -151,7 +151,13 @@ class RadarViewModel( transponders = allRadios.filter { it.downlinkLow != null } if (allRadios.isNotEmpty()) { 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) } } if (!pass.isDeepSpace) { @@ -228,7 +234,16 @@ class RadarViewModel( // Compute toggle state before the update so we don't read post-update value val isTogglingOff = _uiState.value.transceivers.selectedUuid == 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 // avoid resetting a user-adjusted TX base on re-expand if (!isTogglingOff) { @@ -296,6 +311,15 @@ class RadarViewModel( is RadarAction.CwToggleExpanded -> { _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) } + } } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index 4afd0aa8..28f8bbe2 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -18,7 +18,6 @@ package com.rtbishop.look4sat.feature.radar import android.app.Activity -import android.content.Context import android.view.LayoutInflater import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -145,6 +144,7 @@ fun CalculatorPage( selectedUuid: String?, orbitalPos: OrbitalPos?, cw: CwSubState, + calculatorOffsetKHz: String = "", onAction: (RadarAction) -> Unit, requestMicPermission: () -> Unit = {}, modifier: Modifier = Modifier @@ -196,6 +196,8 @@ fun CalculatorPage( DopplerFrequencyCalculator( transponder = selectedTransceiver, orbitalPos = orbitalPos, + offsetKHz = calculatorOffsetKHz, + onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) }, modifier = Modifier.fillMaxWidth() ) @@ -548,6 +550,8 @@ private enum class EditedField { TX, PASSBAND, RX } private fun DopplerFrequencyCalculator( transponder: SatRadio, orbitalPos: OrbitalPos?, + offsetKHz: String = "", + onOffsetChange: (String) -> Unit = {}, modifier: Modifier = Modifier ) { if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return @@ -556,17 +560,6 @@ private fun DopplerFrequencyCalculator( var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) } var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) } 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) } val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L @@ -752,15 +745,7 @@ private fun DopplerFrequencyCalculator( ) { BasicTextField( value = offsetKHz, - onValueChange = { newValue -> - offsetKHz = newValue - catnum?.let { cn -> - context.getSharedPreferences( - context.packageName + "_preferences", - Context.MODE_PRIVATE - ).edit().putString("offset_khz_$cn", newValue).apply() - } - }, + onValueChange = onOffsetChange, singleLine = true, textStyle = TextStyle( fontSize = 16.sp, diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index 0f57a1de..215dba12 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -1,5 +1,6 @@ package com.rtbishop.look4sat.feature.status +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.SatStatus 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.R import com.rtbishop.look4sat.core.presentation.layoutPadding +import java.text.SimpleDateFormat 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. */ 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. * 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 viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) 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 -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?>(null) } Column(modifier = Modifier.fillMaxSize().layoutPadding()) { StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) @@ -122,7 +158,21 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) { } 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) */ @Composable -private fun ReportDialog(statusName: String, day: SatDay, reports: Map, onDismiss: () -> Unit) { +private fun ReportDialog( + statusName: String, + day: SatDay, + reports: Map, + 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] } - 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()) { - 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)) } else { LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { @@ -312,6 +393,110 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map 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 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 ss = cal.get(Calendar.SECOND).toString().padStart(2, '0') return "$day$month $year - $hh:$mm:$ss" -} \ No newline at end of file +} + +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)) +} diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt index 49371011..1686fe91 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt @@ -4,14 +4,36 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer 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.SatStatus import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository 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.StateFlow import kotlinx.coroutines.flow.update 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( val isLoading: Boolean = false, @@ -19,11 +41,13 @@ data class SatStatusUiState( val statuses: List = emptyList(), val reports: Map = emptyMap(), val fetchedAtUtcMs: Long = 0L, - val error: String? = null + val error: String? = null, + val upload: AmSatUploadUiState = AmSatUploadUiState() ) class SatStatusViewModel( - private val amSatRepo: IAmSatRepository + private val amSatRepo: IAmSatRepository, + private val settingsRepo: ISettingsRepo ) : ViewModel() { 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 { fun factory(container: IMainContainer) = viewModelFactory { initializer { - SatStatusViewModel(container.amSatRepo) + SatStatusViewModel(container.amSatRepo, container.settingsRepo) } } } } + +private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")