From c2fc5688b96a155ebeb6154e020ea55dc5a7851c Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Thu, 3 Sep 2026 15:39:34 +0800 Subject: [PATCH] Added AMSAT satellite status reports submission (#248) Co-authored-by: atsunatsu --- .../java/com/rtbishop/look4sat/MainScreen.kt | 1 + .../core/data/repository/AmSatRepository.kt | 43 +++ .../core/data/repository/SettingsRepo.kt | 13 + .../look4sat/core/data/source/RemoteSource.kt | 19 ++ .../core/data/repository/DatabaseRepoTest.kt | 6 + .../core/data/repository/SelectionRepoTest.kt | 4 + .../domain/model/AmSatReportSubmission.kt | 17 + .../domain/repository/IAmSatRepository.kt | 5 + .../core/domain/repository/ISettingsRepo.kt | 5 + .../core/domain/source/IRemoteSource.kt | 1 + .../look4sat/core/presentation/Components.kt | 5 + .../src/main/res/values-zh/strings.xml | 20 ++ .../src/main/res/values/strings.xml | 20 ++ .../feature/status/SatStatusScreen.kt | 291 +++++++++++++++++- .../feature/status/SatStatusViewModel.kt | 236 ++++++++++++-- 15 files changed, 659 insertions(+), 27 deletions(-) create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/AmSatReportSubmission.kt diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 8ed09a78..46a0d042 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -68,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.ElevationThresholds 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 979276f2..2303daf8 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 @@ -49,6 +52,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 5f7de0a1..8903b960 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 @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.json.JSONObject +import java.util.Locale class SettingsRepo( private val locationManager: LocationManager, @@ -504,4 +505,16 @@ class SettingsRepo( } preferences.edit { putString(keySatelliteOffsets, updated) } } + + //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 5af307fa..f548b7f7 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 @@ -23,8 +23,10 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.NetworkResult 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( @@ -97,4 +99,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 d4cb7be9..3dcd0b50 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 @@ -128,6 +128,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 { @@ -229,6 +231,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou 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/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt index 97ccbf6d..30582090 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt @@ -175,6 +175,10 @@ class SelectionRepoTest { override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = Unit + + override fun getAmSatCallsign(): String = "" + + override fun setAmSatCallsign(callsign: String) = Unit } } 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 89fdc131..c432df0a 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 @@ -85,4 +85,9 @@ interface ISettingsRepo { 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 f6d47488..6c4e4e95 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 @@ -32,4 +32,5 @@ interface IRemoteSource { suspend fun getNetworkStream(url: String): NetworkResult 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 d8118566..4b6ffa73 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 @@ -305,6 +305,7 @@ fun InfoDialog( title: String, onDismiss: () -> Unit, onAccept: () -> Unit, + extraAction: (@Composable () -> Unit)? = null, content: @Composable () -> Unit ) { DialogShell(onDismissRequest = onDismiss) { padding -> @@ -323,6 +324,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 0bafcc45..9d8d4b03 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -30,6 +30,26 @@ 仅遥测 未听到 乘组活跃 + 上传 + 返回 + 上传报告到 AMSAT + 状态 + 呼号 + 网格 + 时间:当前 UTC %s + 提交到 AMSAT + 确认上传 + 确认上传到 AMSAT + 这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s + 未填写 + 报告已上传 + 上传失败:%s + 请填写呼号 + 网格应为 4 或 6 位 Maidenhead 定位符 + 活跃 + 遥测/信标 + 未听到 + ISS 语音 模式:%s diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 12d170c0..4de786ef 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -83,6 +83,26 @@ Telemetry Only Not Heard Crew Active + Upload + Back + Upload report to AMSAT + Status + Callsign + Grid + Time: current UTC %s + Submit to AMSAT + Upload + Confirm AMSAT upload + This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s + Not provided + 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/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 5f9fc4d7..2b2ebab7 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,14 @@ 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.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults 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,15 +57,32 @@ 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, + val color: Color +) + +private val UPLOAD_OPTIONS = listOf( + UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)), + UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)), + UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)), + UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100)) +) + /** * Map AMSAT status text to Material3 colorScheme colors. * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. @@ -97,11 +120,33 @@ 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, + onRequestSubmitUpload = viewModel::requestSubmitConfirmation, + onConfirmSubmitUpload = viewModel::confirmSubmitReport, + onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation, + 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, + onRequestSubmitUpload: () -> Unit, + onConfirmSubmitUpload: (String) -> Unit, + onDismissSubmitConfirmation: () -> Unit, + onDismissUpload: () -> Unit +) { var selectedDay by remember { mutableStateOf?>(null) } Column(modifier = Modifier.fillMaxSize().layoutPadding()) { StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) @@ -136,7 +181,23 @@ 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, + onRequestSubmitReport = onRequestSubmitUpload, + onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) }, + onDismissSubmitConfirmation = onDismissSubmitConfirmation, + onDismiss = { + selectedDay = null + onDismissUpload() + } + ) } } @@ -290,13 +351,57 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { } } -/** Report detail dialog (callsign / date / time / grid) */ +/** Report detail dialog (callsign / date / time / grid) + AMSAT report upload panel. */ @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, + onRequestSubmitReport: () -> Unit, + onConfirmSubmitReport: () -> Unit, + onDismissSubmitConfirmation: () -> 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(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload) + ) + } + ) { + AnimatedVisibility(visible = upload.isExpanded) { + AmSatUploadPanel( + statusName = statusName, + upload = upload, + onReportChange = onReportChange, + onCallsignChange = onCallsignChange, + onGridChange = onGridChange, + onRequestSubmitReport = onRequestSubmitReport + ) + } + if (upload.isConfirmingSubmit) { + AmSatUploadConfirmDialog( + statusName = statusName, + upload = upload, + onConfirm = onConfirmSubmitReport, + onDismiss = onDismissSubmitConfirmation + ) + } 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)) { @@ -326,6 +431,171 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map Unit, + onCallsignChange: (String) -> Unit, + onGridChange: (String) -> Unit, + onRequestSubmitReport: () -> 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 -> + val selected = upload.selectedReport == option.apiValue + FilterChip( + selected = selected, + onClick = { onReportChange(option.apiValue) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (selected) Color.White else option.color) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(option.labelResId), maxLines = 1) + } + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = option.color.copy(alpha = 0.10f), + labelColor = MaterialTheme.colorScheme.onSurface, + selectedContainerColor = option.color, + selectedLabelColor = Color.White + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = option.color.copy(alpha = 0.60f), + selectedBorderColor = option.color + ) + ) + } + } + 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 = onRequestSubmitReport, + 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 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 +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 { @@ -343,3 +613,10 @@ private fun formatFetchedAt(utcMs: Long): String { "$day${MONTH_ABBR[monthIndex]} $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)) +} 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..8cbe8db5 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,38 @@ 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.model.SatStatusPage 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 isConfirmingSubmit: Boolean = false, + val message: String? = null, + val error: String? = null +) data class SatStatusUiState( val isLoading: Boolean = false, @@ -19,11 +43,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)) @@ -39,15 +65,7 @@ class SatStatusViewModel( try { val page = amSatRepo.fetchStatus() if (page != null && page.statuses.isNotEmpty()) { - _uiState.update { - it.copy( - isLoading = false, - isRefreshing = false, - statuses = page.statuses, - reports = page.reports, - fetchedAtUtcMs = page.fetchedAtUtcMs - ) - } + applyStatusPage(page) } else { _uiState.update { it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed") @@ -68,14 +86,7 @@ class SatStatusViewModel( try { val page = amSatRepo.fetchStatus() if (page != null && page.statuses.isNotEmpty()) { - _uiState.update { - it.copy( - isRefreshing = false, - statuses = page.statuses, - reports = page.reports, - fetchedAtUtcMs = page.fetchedAtUtcMs - ) - } + applyStatusPage(page) } else { _uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") } } @@ -85,11 +96,196 @@ class SatStatusViewModel( } } + private fun applyStatusPage(page: SatStatusPage) { + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + statuses = page.statuses, + reports = page.reports, + fetchedAtUtcMs = page.fetchedAtUtcMs, + error = null + ) + } + } + + 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, isConfirmingSubmit = false, message = null, error = null) + } + ) + } + } + + fun collapseUploadPanel() { + _uiState.update { + it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)) + } + } + + fun setUploadReport(report: String) { + _uiState.update { + it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, 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, isConfirmingSubmit = false, 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, isConfirmingSubmit = false, message = null, error = null)) + } + } + + 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 + if (upload.isSubmitting) return + val prepared = validateUpload(upload) ?: return + + settingsRepo.setAmSatCallsign(prepared.callsign) + _uiState.update { + it.copy( + upload = it.upload.copy( + callsign = prepared.callsign, + gridSquare = prepared.grid, + isSubmitting = true, + isConfirmingSubmit = false, + error = null, + message = null + ) + ) + } + viewModelScope.launch { + val result = amSatRepo.submitReport( + AmSatReportSubmission( + name = satelliteName, + report = prepared.report, + callsign = prepared.callsign, + gridSquare = prepared.grid, + reportedAtUtcMillis = System.currentTimeMillis() + ) + ) + if (result.success) { + _uiState.update { + it.copy( + upload = it.upload.copy( + isSubmitting = false, + callsign = prepared.callsign, + gridSquare = prepared.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 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 { + 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 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})?$")