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 815fb997..f1dd7e9d 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 @@ -76,6 +76,7 @@ class SettingsRepo( private val keyStateOfUtc = "stateOfUtc" private val keyStateOfLightTheme = "stateOfLightTheme" private val keyStateOfNightMode = "stateOfNightMode" + private val keyStateOfRoamingLive = "stateOfRoamingLive" private val keyStationAltitude = "stationAltitude" private val keyStationLatitude = "stationLatitude" private val keyStationLongitude = "stationLongitude" @@ -351,6 +352,7 @@ class SettingsRepo( putBoolean(keyStateOfUtc, new.stateOfUtc) putBoolean(keyStateOfLightTheme, new.stateOfLightTheme) putBoolean(keyStateOfNightMode, new.stateOfNightMode) + putBoolean(keyStateOfRoamingLive, new.stateOfRoamingLive) putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putString(keySstvMode, new.sstvMode) @@ -368,6 +370,7 @@ class SettingsRepo( stateOfUtc = preferences.getBoolean(keyStateOfUtc, false), stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false), stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), + stateOfRoamingLive = preferences.getBoolean(keyStateOfRoamingLive, true), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true), sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index ef830eb4..610d8b11 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -58,6 +58,7 @@ data class OtherSettings( val stateOfUtc: Boolean, val stateOfLightTheme: Boolean, val stateOfNightMode: Boolean = false, + val stateOfRoamingLive: Boolean = true, val shouldSeeWarning: Boolean, val shouldSeeWhatsNew: Boolean, val sstvMode: String = "Auto", diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index a9ff9f22..775b4b41 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -186,6 +186,7 @@ Radar taramasını etkinleştir Radar görünümünü döndürmek için sensörleri kullan Kırmızı gece filtresini etkinleştir + Roaming konumunu canlı güncelle Elevasyon vurgusu Düşük < %1$d° diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 3f179459..fab23541 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -10,6 +10,7 @@ 过境 雷达 匹配 + 漫游 地图 设置 @@ -161,6 +162,7 @@ 启用雷达扫描动画 使用传感器旋转雷达视图 启用红色夜间模式 + 漫游位置实时更新 仰角高亮 低 < %1$d° diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index ca7ae6a0..01ef7861 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -197,6 +197,7 @@ Enable radar sweep animation Use sensors to rotate radar view Enable red night mode filter + Update Roaming position live Elevation highlight Low < %1$d° diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt index 54cfbedd..07690fd3 100644 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt @@ -18,20 +18,21 @@ package com.rtbishop.look4sat.feature.roaming import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,164 +40,214 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.rtbishop.look4sat.core.presentation.CardButton - -private val InfoCyan = Color(0xFF0CCFE2) -private val GridBlue = Color(0xFF12ACE0) -private val LabelBlue = Color(0xFF135678) -private val PositionRed = Color(0xFFFF0000) -private val DividerWhite = Color(0xFFF7F7F7) +import com.rtbishop.look4sat.core.presentation.LocalSpacing +import com.rtbishop.look4sat.core.presentation.ScreenColumn +import com.rtbishop.look4sat.core.presentation.TopBar +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +/** + * Roaming (漫游) page: a faithful port of the QTH定位器 location panel — + * GPS status + date/time header, DMS & decimal coordinates, the big 8-char + * Maidenhead locator, and a 3x3 grid of neighboring squares (center column + * and row are wider/taller, matching the reference app). Styled with the + * Look4Sat Material 3 theme. + */ @Composable fun RoamingScreen(viewModel: RoamingViewModel) { val state by viewModel.uiState.collectAsStateWithLifecycle() - Column(modifier = Modifier.fillMaxSize()) { - InfoHeader(state = state, onGpsClick = viewModel::onGpsClick) - GridPanel(state = state) + ScreenColumn( + topBar = { + TopBar { Text(text = "漫游", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + } + ) { + val spacing = LocalSpacing.current + Column(verticalArrangement = Arrangement.spacedBy(spacing.medium)) { + InfoCard(state = state) + GridCard(state = state) + } } } @Composable -private fun InfoHeader(state: RoamingState, onGpsClick: () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(InfoCyan) - .padding(horizontal = 16.dp, vertical = 10.dp) - ) { - // Top row: GPS status + big locator - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "GPS", - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - color = Color.Black - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(if (state.qthLocator != "----") Color(0xFF3BAF52) else Color.Gray) - ) - Spacer(modifier = Modifier.weight(1f)) +private fun InfoCard(state: RoamingState) { + val colorScheme = MaterialTheme.colorScheme + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Top bar: GPS status + date + time + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "GPS", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(if (state.gpsEnabled) colorScheme.primary else colorScheme.outline) + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()), + fontSize = 14.sp, + color = colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + } + // Coordinates: DMS + decimal + CoordinateRow(label = "纬度", value = state.latitude, isLat = true) + CoordinateRow(label = "经度", value = state.longitude, isLat = false) + // Big locator, centered Text( text = state.qthLocator, - fontSize = 30.sp, + fontSize = 34.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, - color = Color.Black + color = colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) } - Spacer(modifier = Modifier.height(10.dp)) - // Lat / Lon rows with DMS + decimal - CoordinateRow(label = "Lat", value = state.latitude, isLat = true) - Spacer(modifier = Modifier.height(4.dp)) - CoordinateRow(label = "Lon", value = state.longitude, isLat = false) - Spacer(modifier = Modifier.height(10.dp)) - CardButton( - onClick = onGpsClick, - text = "GPS 定位", - modifier = Modifier.fillMaxWidth() - ) } } @Composable private fun CoordinateRow(label: String, value: Double, isLat: Boolean) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { + val colorScheme = MaterialTheme.colorScheme + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( text = label, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = Color.Black, - modifier = Modifier.width(44.dp) - ) - Text( - text = formatDms(value, isLat), fontSize = 15.sp, - color = Color.Black + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface, + modifier = Modifier.width(40.dp) ) + Text(text = formatDms(value, isLat), fontSize = 15.sp, color = colorScheme.onSurface) Spacer(modifier = Modifier.weight(1f)) Text( text = "${formatDecimal(value)}°", fontSize = 14.sp, - color = LabelBlue + color = colorScheme.onSurfaceVariant ) } } +/** + * 3x3 grid of 4-char squares. Column widths 21.4% : 56.2% : 21.4% and row + * heights 31.5% : 35.9% : 31.7% mirror the reference app: the center column + * is ~2.6x wider and the center row is the tallest, so the current square + * dominates the panel. + */ @Composable -private fun GridPanel(state: RoamingState) { - Column( - modifier = Modifier - .fillMaxSize() - .background(GridBlue) - .padding(4.dp) - ) { - // 3x3 grid: row 0 = north - for (row in 0..2) { - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - for (col in 0..2) { - val index = row * 3 + col - val isCenter = index == 4 - GridCell( - label = state.gridSquares.getOrElse(index) { "----" }, - isCenter = isCenter, - showMarker = isCenter && state.qthLocator != "----", - modifier = Modifier.weight(1f) - ) - } - } - if (row < 2) Spacer(modifier = Modifier.height(4.dp)) +private fun GridCard(state: RoamingState) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + GridRow(state, row = 0, heightFraction = 0.315f) + GridRow(state, row = 1, heightFraction = 0.359f) + GridRow(state, row = 2, heightFraction = 0.317f) } } } +@Composable +private fun GridRow(state: RoamingState, row: Int, heightFraction: Float) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + GridCell( + label = state.gridSquares.getOrElse(row * 3 + 0) { "----" }, + isCenter = false, + markerX = state.markerX, + markerY = state.markerY, + modifier = Modifier.weight(0.214f) + ) + GridCell( + label = state.gridSquares.getOrElse(row * 3 + 1) { "----" }, + isCenter = row == 1, + markerX = state.markerX, + markerY = state.markerY, + showMarker = row == 1 && state.qthLocator != "----", + modifier = Modifier.weight(0.562f) + ) + GridCell( + label = state.gridSquares.getOrElse(row * 3 + 2) { "----" }, + isCenter = false, + markerX = state.markerX, + markerY = state.markerY, + modifier = Modifier.weight(0.214f) + ) + } +} + @Composable private fun GridCell( label: String, isCenter: Boolean, - showMarker: Boolean, + markerX: Float, + markerY: Float, + showMarker: Boolean = false, modifier: Modifier = Modifier ) { - Box( + val colorScheme = MaterialTheme.colorScheme + val cellBackground = if (isCenter) colorScheme.secondaryContainer else colorScheme.surfaceVariant + val labelColor = if (isCenter) colorScheme.onSecondaryContainer else colorScheme.onSurfaceVariant + BoxWithConstraints( contentAlignment = Alignment.Center, modifier = modifier - .aspectRatio(1f) - .background(if (isCenter) InfoCyan else GridBlue) - .border(1.dp, DividerWhite) + .height(84.dp) + .clip(MaterialTheme.shapes.medium) + .background(cellBackground) ) { + // Capture constraints at the BoxWithConstraints scope (Column scope would shadow them) + val width = maxWidth + val height = maxHeight Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = label, - fontSize = if (isCenter) 26.sp else 16.sp, + fontSize = if (isCenter) 22.sp else 14.sp, fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal, - color = LabelBlue, + color = labelColor, textAlign = TextAlign.Center ) if (showMarker) { Spacer(modifier = Modifier.height(4.dp)) + // Red marker at the fractional position inside the center cell + val density = LocalDensity.current + val offsetX = with(density) { (width * (markerX - 0.5f)).toPx().toDp() } + val offsetY = with(density) { (height * (markerY - 0.5f)).toPx().toDp() } Box( modifier = Modifier .size(12.dp) - .background(PositionRed) - .border(2.dp, DividerWhite) + .background(colorScheme.error) + .offset(x = offsetX, y = offsetY) ) } } diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt index 70444867..d0882671 100644 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt @@ -27,19 +27,24 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.utility.positionToQth import com.rtbishop.look4sat.core.domain.utility.qthNeighbors import com.rtbishop.look4sat.core.domain.utility.qthToSquare +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch data class RoamingState( val latitude: Double = 0.0, val longitude: Double = 0.0, - val altitude: Double = 0.0, val qthLocator: String = "----", val gridSquares: List = List(9) { "----" }, - val isUpdating: Boolean = false, - val messageResId: Int = 0 + /** Fractional position of the red marker inside the center cell, 0..1. */ + val markerX: Float = 0.5f, + val markerY: Float = 0.5f, + /** True when a fresh GPS fix is available (station position updated recently). */ + val gpsEnabled: Boolean = false, + val isUpdating: Boolean = false ) class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() { @@ -47,19 +52,42 @@ class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() { private val _uiState = MutableStateFlow(RoamingState()) val uiState: StateFlow = _uiState + private var liveEnabled: Boolean = true + init { + // Follow station position updates viewModelScope.launch { settingsRepo.stationPosition.collect { geoPos -> - _uiState.update { it.copy(isUpdating = false, messageResId = 0).withPosition(geoPos) } + _uiState.update { state -> + state.copy(isUpdating = false, gpsEnabled = isFixFresh(geoPos)).withPosition(geoPos) + } + } + } + // Follow the "live update" preference; when enabled, periodically refresh the fix + viewModelScope.launch { + settingsRepo.otherSettings.collect { other -> + liveEnabled = other.stateOfRoamingLive + if (liveEnabled) refreshPosition() + } + } + viewModelScope.launch { + while (isActive) { + delay(30_000L) + if (liveEnabled) refreshPosition() + _uiState.update { state -> + state.copy(gpsEnabled = isFixFresh(settingsRepo.stationPosition.value)) + } } } } - fun onGpsClick() { - val success = settingsRepo.setStationPosition() - if (success) { - _uiState.update { it.copy(isUpdating = true) } - } + /** Ask the settings repo for the latest fix (triggers GPS request if stale). */ + private fun refreshPosition() { + settingsRepo.setStationPosition() + } + + private fun isFixFresh(pos: GeoPos): Boolean { + return pos.timestamp > 0L && System.currentTimeMillis() - pos.timestamp < 600_000L } private fun RoamingState.withPosition(pos: GeoPos): RoamingState { @@ -69,12 +97,31 @@ class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() { return copy( latitude = pos.latitude, longitude = pos.longitude, - altitude = pos.altitude, qthLocator = locator, - gridSquares = squares + gridSquares = squares, + markerX = markerFraction(pos.longitude, square, isLat = false), + markerY = markerFraction(pos.latitude, square, isLat = true) ) } + /** + * Fractional position of [value] (lon or lat) inside its 4-char square. + * A square spans 2° of longitude and 1° of latitude; the fraction is + * measured from the square's south-west corner, 0..1. + */ + private fun markerFraction(value: Double, square: String, isLat: Boolean): Float { + if (square.length != 4) return 0.5f + val fieldIndex = (square[if (isLat) 1 else 0].uppercaseChar().code - 65).coerceIn(0, 17) + val squareIndex = square[if (isLat) 3 else 2].digitToIntOrNull() ?: return 0.5f + val cellSize = if (isLat) 1.0 else 2.0 + val cellStart = if (isLat) { + fieldIndex * 10.0 + squareIndex - 90.0 + } else { + fieldIndex * 20.0 + squareIndex * 2.0 - 180.0 + } + return ((value - cellStart) / cellSize).coerceIn(0.0, 1.0).toFloat() + } + companion object { fun factory(container: IMainContainer) = viewModelFactory { initializer { RoamingViewModel(container.settingsRepo) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index ebfefc44..468a4b73 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -488,7 +488,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni ElevatedCard( modifier = Modifier .fillMaxWidth() - .height(268.dp) + .height(296.dp) ) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Text( @@ -510,6 +510,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { onAction(SettingsAction.ToggleNightMode(it)) } + SwitchRow(R.string.prefs_other_switch_roaming_live, settings.stateOfRoamingLive) { + onAction(SettingsAction.ToggleRoamingLive(it)) + } } } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 928cf60b..4b7e667f 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -64,6 +64,7 @@ sealed interface SettingsAction { data class ToggleSensor(val value: Boolean) : SettingsAction data class ToggleLightTheme(val value: Boolean) : SettingsAction data class ToggleNightMode(val value: Boolean) : SettingsAction + data class ToggleRoamingLive(val value: Boolean) : SettingsAction // Remote control data class UpdateRC(val settings: RCSettings) : SettingsAction diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index f43e91f6..fc646d14 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -121,6 +121,7 @@ class SettingsViewModel( is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) } is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) } is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) } + is SettingsAction.ToggleRoamingLive -> settingsRepo.updateOtherSettings { it.copy(stateOfRoamingLive = action.value) } // Remote control & data sources is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)