diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4e74c49..07fcc40e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,19 +39,22 @@ jobs: run: | echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1) + VERSION=${TAG_NAME#v} + SIGNED_APK="app/build/outputs/apk/release/look4sat-${VERSION}.apk" BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1) ${BUILD_TOOLS}apksigner sign \ --ks keystore.jks \ --ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \ --ks-key-alias ${{ secrets.KEY_ALIAS }} \ --key-pass pass:${{ secrets.KEY_PASSWORD }} \ - --out app/build/outputs/apk/release/look4sat.apk \ + --out "$SIGNED_APK" \ "$APK" rm keystore.jks + echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV" - name: Create Release env: GH_TOKEN: ${{ github.token }} run: | gh release create $TAG_NAME --title=$TAG_NAME --generate-notes - gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk + gh release upload $TAG_NAME "$SIGNED_APK" diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 30b95f6b..7fd41d4e 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -86,7 +86,6 @@ import com.rtbishop.look4sat.feature.mutual.MutualViewModel import com.rtbishop.look4sat.feature.passes.PassesDestination import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.roaming.RoamingScreen -import com.rtbishop.look4sat.feature.roaming.RoamingViewModel import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination @@ -227,11 +226,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { ) } entry { - val roamingViewModel: RoamingViewModel = viewModel( - viewModelStoreOwner = context as ViewModelStoreOwner, - factory = RoamingViewModel.factory(container) - ) - RoamingScreen(viewModel = roamingViewModel) + RoamingScreen() } entry { SettingsDestination() 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 07690fd3..bcb2b5c3 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 @@ -22,11 +22,11 @@ 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.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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 @@ -37,9 +37,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -47,6 +49,10 @@ 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.domain.repository.IContainerProvider +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 com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.TopBar @@ -60,19 +66,37 @@ import java.util.Locale * 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. + * + * Coordinates come straight from the shared settingsRepo.stationPosition + * StateFlow — the exact same source the Settings page shows — so the two + * pages can never disagree. The red marker is placed at the fractional + * position derived from the 3rd character pair of the locator (ported from + * the QTH定位器 app), scaled to the actual cell size, so it stays accurate + * on any screen (phones, tablets, wide/narrow). */ @Composable -fun RoamingScreen(viewModel: RoamingViewModel) { - val state by viewModel.uiState.collectAsStateWithLifecycle() +fun RoamingScreen() { + val context = LocalContext.current + val container = (context.applicationContext as IContainerProvider).getMainContainer() + val stationPos by container.settingsRepo.stationPosition.collectAsStateWithLifecycle() + val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() + + val state = remember(stationPos, otherSettings.stateOfRoamingLive) { + RoamingState.fromPosition(stationPos) + } + ScreenColumn( topBar = { TopBar { Text(text = "漫游", fontSize = 20.sp, fontWeight = FontWeight.Bold) } } ) { val spacing = LocalSpacing.current - Column(verticalArrangement = Arrangement.spacedBy(spacing.medium)) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(spacing.medium) + ) { InfoCard(state = state) - GridCard(state = state) + GridCard(state = state, modifier = Modifier.weight(1f)) } } } @@ -161,32 +185,34 @@ private fun CoordinateRow(label: String, value: Double, isLat: Boolean) { * dominates the panel. */ @Composable -private fun GridCard(state: RoamingState) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { +private fun GridCard(state: RoamingState, modifier: Modifier = Modifier) { + ElevatedCard( + modifier = modifier.fillMaxWidth() + ) { Column( modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + .fillMaxSize() + .padding(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - GridRow(state, row = 0, heightFraction = 0.315f) - GridRow(state, row = 1, heightFraction = 0.359f) - GridRow(state, row = 2, heightFraction = 0.317f) + GridRow(state, row = 0, weight = 0.315f) + GridRow(state, row = 1, weight = 0.359f) + GridRow(state, row = 2, weight = 0.317f) } } } @Composable -private fun GridRow(state: RoamingState, row: Int, heightFraction: Float) { +private fun ColumnScope.GridRow(state: RoamingState, row: Int, weight: Float) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) + modifier = Modifier + .fillMaxWidth() + .weight(weight), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { GridCell( label = state.gridSquares.getOrElse(row * 3 + 0) { "----" }, isCenter = false, - markerX = state.markerX, - markerY = state.markerY, modifier = Modifier.weight(0.214f) ) GridCell( @@ -200,8 +226,6 @@ private fun GridRow(state: RoamingState, row: Int, heightFraction: Float) { GridCell( label = state.gridSquares.getOrElse(row * 3 + 2) { "----" }, isCenter = false, - markerX = state.markerX, - markerY = state.markerY, modifier = Modifier.weight(0.214f) ) } @@ -211,8 +235,8 @@ private fun GridRow(state: RoamingState, row: Int, heightFraction: Float) { private fun GridCell( label: String, isCenter: Boolean, - markerX: Float, - markerY: Float, + markerX: Float = 0.5f, + markerY: Float = 0.5f, showMarker: Boolean = false, modifier: Modifier = Modifier ) { @@ -222,34 +246,35 @@ private fun GridCell( BoxWithConstraints( contentAlignment = Alignment.Center, modifier = modifier - .height(84.dp) + .fillMaxSize() .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) 22.sp else 14.sp, - fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal, - color = labelColor, - textAlign = TextAlign.Center + Text( + text = label, + fontSize = if (isCenter) 22.sp else 13.sp, + fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal, + color = labelColor, + textAlign = TextAlign.Center + ) + if (showMarker) { + // Red marker: absolute position from the top-left of the cell. + // markerX/markerY are 0..1 fractions (from the reference app's + // 3rd-pair mapping), so offset = fraction * cell size, minus half + // the marker size to center the 12dp square on the point. + val density = LocalDensity.current + val dotSize = 12.dp + val offsetX = with(density) { (width * markerX).toPx().toDp() - dotSize / 2 } + val offsetY = with(density) { (height * markerY).toPx().toDp() - dotSize / 2 } + Box( + modifier = Modifier + .size(dotSize) + .background(colorScheme.error) + .offset(x = offsetX, y = offsetY) ) - 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(colorScheme.error) - .offset(x = offsetX, y = offsetY) - ) - } } } } diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingState.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingState.kt new file mode 100644 index 00000000..70fcfbe5 --- /dev/null +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingState.kt @@ -0,0 +1,76 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.feature.roaming + +import com.rtbishop.look4sat.core.domain.predict.GeoPos +import com.rtbishop.look4sat.core.domain.utility.positionToQth +import com.rtbishop.look4sat.core.domain.utility.qthNeighbors +import com.rtbishop.look4sat.core.domain.utility.qthToSquare + +data class RoamingState( + val latitude: Double = 0.0, + val longitude: Double = 0.0, + val qthLocator: String = "----", + val gridSquares: List = List(9) { "----" }, + /** 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 +) { + companion object { + /** + * Builds the state from a [GeoPos]. The 3x3 grid and the marker + * position are derived from the 8-char locator exactly like the + * QTH定位器 app: the marker is placed by the 3rd character pair + * (e.g. the "ih" in "OL42ih45"), mapped to a 0..1 fraction. + */ + fun fromPosition(pos: GeoPos): RoamingState { + val locator = positionToQth(pos.latitude, pos.longitude) ?: return RoamingState() + val square = qthToSquare(locator) + val squares = if (square != "----") qthNeighbors(square) else List(9) { "----" } + val fresh = pos.timestamp > 0L && System.currentTimeMillis() - pos.timestamp < 600_000L + return RoamingState( + latitude = pos.latitude, + longitude = pos.longitude, + qthLocator = locator, + gridSquares = squares, + markerX = markerFraction(locator, isLat = false), + markerY = markerFraction(locator, isLat = true), + gpsEnabled = fresh + ) + } + + /** + * Fractional position of the fix inside the 4-char square, derived from + * the 3rd character pair of an 8-char locator (e.g. the "ih" in + * "OL42ih45"). Ported from the QTH定位器 app: it maps the 3rd-pair + * letters (a..x) to a position inside the center cell instead of + * recomputing from coordinates. + * X: a = west edge .. x = east edge (left -> right). Y: a = south .. + * x = north, inverted so the value grows downward on screen, matching + * the reference app. + */ + private fun markerFraction(locator: String, isLat: Boolean): Float { + if (locator.length < 6) return 0.5f + val index = if (isLat) locator[5] else locator[4] + val fraction = (index.lowercaseChar().code - 97).coerceIn(0, 23) / 24f + return if (isLat) 1f - fraction else fraction + } + } +} 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 deleted file mode 100644 index d0882671..00000000 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Look4Sat. Amateur radio satellite tracker and pass predictor. - * Copyright (C) 2019-2026 Arty Bishop and contributors. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.rtbishop.look4sat.feature.roaming - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import com.rtbishop.look4sat.core.domain.predict.GeoPos -import com.rtbishop.look4sat.core.domain.repository.IMainContainer -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 qthLocator: String = "----", - val gridSquares: List = List(9) { "----" }, - /** 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() { - - 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 { 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)) - } - } - } - } - - /** 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 { - val locator = positionToQth(pos.latitude, pos.longitude) ?: return copy(qthLocator = "----") - val square = qthToSquare(locator) - val squares = if (square != "----") qthNeighbors(square) else List(9) { "----" } - return copy( - latitude = pos.latitude, - longitude = pos.longitude, - qthLocator = locator, - 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 468a4b73..af632496 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 @@ -490,7 +490,10 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni .fillMaxWidth() .height(296.dp) ) { - Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { Text( text = stringResource(id = R.string.prefs_other_title), color = MaterialTheme.colorScheme.primary