Compare commits

...
Author SHA1 Message Date
atsunatsu 0ec4c5dc6c chore: bump version to 4.4.7-ba7opf.11.3 (versionCode 484) 2026-09-21 11:35:33 +08:00
atsunatsu 8e8a3849df fix(map): first-call labels now appear exactly when grid lines do
The 11.2 gate only relaxed the pre-return zoom check, but the showLabels
gate below it still required LABEL_ZOOM_SUB (6.5), so first-call labels
stayed hidden in the 6.0..6.5 band where the 4-char grid lines render.
- Gate showLabels on GRID_ZOOM_SUB (6.0) when showFirstCallLabels is on.
- Relax MIN_LABEL_CELL_PX (48) to 0.6x for first-call mode so 1° cells
  (~45 px on the equator at zoom 6.0) are not filtered out.
Normal grid-code labels are unchanged (still LABEL_ZOOM_SUB).
2026-09-21 11:35:10 +08:00
atsunatsu cd65e6c077 fix(res): single-line what's new string, unformatted qso count, si typo
- pass_whatsnew_message rewritten as a single line with escaped \n bullets:
  real newlines in the value are invalid for aapt2 ('not a valid string').
- Escape the apostrophe in "map's"; mark grid_qso_calls_count
  formatted=false (two %d + '·' triggered multiple-substitutions).
- values-si: remove stray comma after map_altitude </string>.
All were latent resource errors surfaced by the forced recompile.
2026-09-21 11:11:52 +08:00
atsunatsu a3d370b1fa chore: bump version to 4.4.7-ba7opf.11.2 (versionCode 483) 2026-09-21 10:52:35 +08:00
atsunatsu bf492e3f8b feat(passes/radar): port pass history + compass calibration, cumulative what's new
- Pass history (Iu-yang1 fork): PassesSettings.hoursBefore look-back window;
  second 'Time before' slider in the pass filter; completed passes stay
  listed (progress clamped to [0,1]) in the history window.
- Compass calibration (Iu-yang1 fork): OtherSettings.compassOffsetDegrees,
  CompassAccuracy from the magnetic sensor, calibration dialog with
  figure-eight accuracy meter, corrected heading and manual offset field;
  RadarViewModel applies the offset.
- What's New dialog now shows the cumulative 4.4.6->4.4.7 changelog and
  follows the system language (values/ + values-zh/).
- pass_filter_history_hours localized in es/ru/si/tr/uk.
2026-09-21 10:42:45 +08:00
atsunatsu 3591960d55 feat(map): show first-call labels as soon as grid lines appear
Move the first-call label gate from LABEL_ZOOM_SUB (6.5) to GRID_ZOOM_SUB
(6.0): worked (green) cells are labelled with their first callsign as soon
as the 4-char grid lines render, not only at the name-label zoom.
2026-09-21 10:42:37 +08:00
atsunatsu 3f7a087938 chore: bump version to 4.4.7-ba7opf.11.1 (versionCode 482) 2026-09-21 09:44:46 +08:00
atsunatsu 4b82332133 feat(map): first-call callsign label toggle in VUCC grid mode
Add a 'first call' pill toggle below the grid-mode switch (map top-right,
visible only in grid mode + VUCC award). When on, worked (green) cells are
labelled with the first callsign worked in that grid (earliest confirmed
QSO by time) instead of the Maidenhead code; non-worked cells get no label
at all. Persisted via OtherSettings.stateOfMapFirstCall (full MVI chain).
The feature is VUCC-only: All and other award overlays never draw it.
2026-09-21 09:44:43 +08:00
atsunatsu dcd16a0196 chore: bump version to 4.4.7-ba7opf.11 (versionCode 481) 2026-09-19 04:27:55 +08:00
atsunatsu 9e88609efc feat(amsat): show consecutive same-status report count in status cells
Day-cell numbers now count the most recent consecutive reports sharing the
newest status (counting back from the newest report, stopping at the first
different status), instead of the total report count of the day. Empty gaps
do not break the streak; statuses compare by exact report text; counting
does not cross day boundaries.

- SatDay gains streakCount (computed in AmSatRepository.buildStatuses)
- DayCell renders day.streakCount instead of slot count sum
- Add real org.json to core:data unit tests (android.jar stub cannot parse)
- Add 2 streak tests: mixed-status slot breaks, all-same-status counts all
2026-09-19 04:27:19 +08:00
30 changed files with 567 additions and 57 deletions

No files matched your search

@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
ksp(libs.androidx.room.compiler)
implementation(libs.kotlin.coroutines)
implementation(libs.other.okhttp)
// android.jar's org.json is a stub ("not mocked") in unit tests;
// provide a real implementation so JSON parsing is testable.
testImplementation(libs.test.json)
}
}
}
@@ -221,7 +221,22 @@ class AmSatRepository(
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
val dayStart = nowSec - (d + 1) * 86400L
val dayEnd = nowSec - d * 86400L
val dayReports = byName[name].orEmpty()
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
.sortedByDescending { it.reportedTimeUtcSec }
val streakCount = if (dayReports.isEmpty()) 0 else {
val newestStatus = dayReports.first().report
dayReports.takeWhile { it.report == newestStatus }.size
}
SatDay(
dateLabel = labels[d],
slots = slots.subList(d * 12, (d + 1) * 12),
streakCount = streakCount
)
}
SatStatus(name = name, days = days)
}
@@ -76,9 +76,11 @@ class SatelliteRepo(
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
val now = System.currentTimeMillis()
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
time = now - settings.hoursBefore * 60L * 60L * 1000L,
hoursAhead = settings.hoursBefore + settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
@@ -27,6 +27,7 @@ import android.view.Display
import android.view.Surface
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -41,7 +42,12 @@ class SensorsRepo(
) : ISensorsRepo, SensorEventListener {
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val rotationSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val magneticSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
private val _compassAccuracy = MutableStateFlow(
if (rotationSensor == null || magneticSensor == null) CompassAccuracy.UNAVAILABLE
else CompassAccuracy.UNRELIABLE
)
private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3)
@@ -50,6 +56,7 @@ class SensorsRepo(
private var hasInitialReading = false
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
return GeomagneticField(
@@ -62,15 +69,41 @@ class SensorsRepo(
override fun enableSensor() {
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
if (rotationSensor == null || magneticSensor == null) {
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
return
}
_compassAccuracy.value = CompassAccuracy.UNRELIABLE
val rotationRegistered = sensorManager.registerListener(this, rotationSensor, SENSOR_RATE_US)
val magneticRegistered = sensorManager.registerListener(this, magneticSensor, SENSOR_RATE_US)
if (!rotationRegistered || !magneticRegistered) {
sensorManager.unregisterListener(this)
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
}
}
override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
updateAccuracy(accuracy)
}
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
when (event.sensor.type) {
Sensor.TYPE_ROTATION_VECTOR -> handleSensorEvent(event)
Sensor.TYPE_MAGNETIC_FIELD -> updateAccuracy(event.accuracy)
}
}
private fun updateAccuracy(accuracy: Int) {
_compassAccuracy.value = when (accuracy) {
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> CompassAccuracy.HIGH
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> CompassAccuracy.MEDIUM
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> CompassAccuracy.LOW
else -> CompassAccuracy.UNRELIABLE
}
}
private fun getDisplayRotation(): Int {
@@ -58,6 +58,7 @@ class SettingsRepo(
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterHoursBefore = "filterHoursBefore"
private val keyFilterMinElevation = "filterMinElevation"
private val keyFilterAosStartMinute = "filterAosStartMinute"
private val keyFilterAosEndMinute = "filterAosEndMinute"
@@ -79,11 +80,13 @@ class SettingsRepo(
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
private val keyStateOfSensors = "stateOfSensors"
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -317,6 +320,7 @@ class SettingsRepo(
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead)
putInt(keyFilterHoursBefore, settings.hoursBefore)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
@@ -328,6 +332,7 @@ class SettingsRepo(
private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
@@ -341,7 +346,8 @@ class SettingsRepo(
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
selectedModes,
hoursBefore
)
}
//endregion
@@ -541,11 +547,13 @@ class SettingsRepo(
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
putBoolean(keyStateOfSensors, new.stateOfSensors)
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
@@ -560,11 +568,13 @@ class SettingsRepo(
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -12,6 +12,10 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class AmSatRepositoryTest {
@@ -75,6 +79,52 @@ class AmSatRepositoryTest {
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
report("r1", "AO-7", "Heard", nowSec - 3600),
report("r2", "AO-7", "Heard", nowSec - 5400),
report("r3", "AO-7", "Not Heard", nowSec - 7000),
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
report("r4", "AO-7", "Heard", nowSec - 63000),
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
report("r5", "AO-7", "Heard", nowSec - 90000),
report("r6", "AO-7", "Not Heard", nowSec - 100000)
// day 2 无报告 → 0
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
val status = page?.statuses?.single()
assertEquals("AO-7", status?.name)
assertEquals(3, status?.days?.size)
assertEquals(2, status?.days?.get(0)?.streakCount)
assertEquals(1, status?.days?.get(1)?.streakCount)
assertEquals(0, status?.days?.get(2)?.streakCount)
}
@Test
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
report("r1", "AO-7", "Heard", nowSec - 1800),
report("r2", "AO-7", "Heard", nowSec - 3600),
report("r3", "AO-7", "Heard", nowSec - 5400),
report("r4", "AO-7", "Heard", nowSec - 30000)
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
}
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
@@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
cacheField.set(this, page)
}
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
private class FakeAmSatRemoteSource(
private val responseDelayMillis: Long = 0L,
private val reportsJson: String = """{"data":[]}"""
) : IRemoteSource {
var catalogRequests = 0
var reportRequests = 0
@@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
reportRequests += 1
return """{"data":[]}"""
return reportsJson
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
private fun amSatReportsJson(vararg reports: String): String =
"""{"data":[${reports.joinToString(",")}]}"""
private fun report(id: String, name: String, status: String, epochSec: Long): String =
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
private fun isoUtc(epochSec: Long): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(epochSec * 1000))
@@ -20,7 +20,8 @@ data class SatSlot(
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
)
/** One satellite, 3 days of state */
@@ -30,7 +30,9 @@ data class PassesSettings(
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
val selectedModes: List<String>,
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
val hoursBefore: Int = 0
)
data class RCSettings(
@@ -60,6 +62,10 @@ data class OtherSettings(
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfMapGrid: Boolean = false,
/** Grid mode: label worked (green) cells with the FIRST callsign worked
* in that grid instead of the Maidenhead code; non-worked cells get no
* label at all. */
val stateOfMapFirstCall: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
@@ -67,7 +73,9 @@ data class OtherSettings(
val highElevation: Double = 45.0,
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
* ephemeris auto-update; only effective after at least one manual sync). */
val stateOfAutoLotwSync: Boolean = true
val stateOfAutoLotwSync: Boolean = true,
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
val compassOffsetDegrees: Float = 0f
)
data class DataSourcesSettings(
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
enum class CompassAccuracy {
UNAVAILABLE,
UNRELIABLE,
LOW,
MEDIUM,
HIGH
}
interface ISensorsRepo {
val sensorData: StateFlow<Pair<Float, Float>>
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
val compassAccuracy: StateFlow<CompassAccuracy>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
fun enableSensor()
fun disableSensor()
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Filtrar pases</string>
<string name="pass_filter_elev">Elevación mínima</string>
<string name="pass_filter_hours">Tiempo por delante</string>
<string name="pass_filter_history_hours">Tiempo anterior</string>
<string name="pass_filter_aos_time">Ventana AOS</string>
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
<string name="pass_filter_deep_space">DeepSpace (período &gt;225min)</string>
@@ -31,6 +31,7 @@
<string name="pass_filter_title">Фильтровать пролеты</string>
<string name="pass_filter_elev">Минимальная элевация</string>
<string name="pass_filter_hours">Период расчёта</string>
<string name="pass_filter_history_hours">Прошедший период</string>
<string name="pass_filter_aos_time">Окно AOS</string>
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (период &gt;225мин)</string>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
<string name="pass_filter_hours">ඉදිරි කාලය</string>
<string name="pass_filter_history_hours">පෙර කාලය</string>
<string name="pass_filter_aos_time">AOS කවුළුව</string>
<string name="pass_filter_invert_time">AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න</string>
<string name="pass_filter_deep_space">DeepSpace (කාලය &gt;225min)</string>
@@ -70,7 +71,7 @@
<string name="map_next">ඊළග</string>
<string name="map_azimuth">දිගංශය: %.1f°</string>
<string name="map_elevation">උත්තෝලනය: %.1f°</string>
<string name="map_altitude">උන්නතාශය: %.0f km</string>,
<string name="map_altitude">උන්නතාශය: %.0f km</string>
<string name="map_distance">පරතරය: %.0f km</string>
<string name="map_latitude">අක්‍ෂාංශ: %.1f°</string>
<string name="map_longitude">දේශාංශ: %.1f°</string>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Geçişleri filtrele</string>
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
<string name="pass_filter_hours">Gösterilecek zaman</string>
<string name="pass_filter_history_hours">Geçmiş süre</string>
<string name="pass_filter_aos_time">AOS aralığı</string>
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
<string name="pass_filter_deep_space">DeepSpace (periyot &gt;225dk)</string>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Фільтр прольотів</string>
<string name="pass_filter_elev">Мінімальне піднесень</string>
<string name="pass_filter_hours">Час вперед</string>
<string name="pass_filter_history_hours">Час назад</string>
<string name="pass_filter_aos_time">Вікно AOS</string>
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (період &gt;225хв)</string>
@@ -28,6 +28,7 @@
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_history_hours">先前时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
@@ -40,7 +41,9 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(先前时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
@@ -109,6 +112,7 @@
<string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string>
<string name="map_first_call_mode">首通呼号</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
@@ -121,7 +125,7 @@
<string name="map_velocity">速度:%.2f km/s</string>
<string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
<string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">首通</string>
@@ -240,6 +244,19 @@
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_compass_calibration_button">校准指南针</string>
<string name="prefs_compass_calibration_title">指南针校准</string>
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
<string name="prefs_compass_accuracy">精度:%s</string>
<string name="prefs_compass_accuracy_high">高——校准完成</string>
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
<string name="prefs_compass_offset">手动航向偏置(°)</string>
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
<string name="prefs_compass_offset_reset">偏置归零</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
@@ -33,6 +33,7 @@
<string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Time ahead</string>
<string name="pass_filter_history_hours">Time before</string>
<string name="pass_filter_aos_time">AOS window</string>
<string name="pass_filter_invert_time">Invert AOS window</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
@@ -51,10 +52,7 @@
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
</string>
<string name="pass_whatsnew_message">All changes since 4.4.6 (the 4.4.7 series):\n\n• Grid-mode map: LoTW confirmed grids in green, roamed grids with blue stripes, current grid highlighted; mode switch pill at the map\'s top-right.\n\n• First-call labels (VUCC view): show the first callsign worked in each green grid instead of the grid code; non-worked cells stay empty.\n\n• Worked grids filtered by operated grid (with an All option); tap a grid to see its QSO detail dialog; per-grid QSO counts.\n\n• Per-operated-grid VUCC counting; polar grid labels; antimeridian fixes; consistent roamed-stripe density.\n\n• WAPC/WAJA map labels follow the system language.\n\n• Grid-mode viewport and award filter survive page switches.\n\n• LoTW sync: direct login, full/incremental modes, live progress, cancel button, auto-sync on app start with its own toggle, last-sync time shown.\n\n• Pass history: filter can look back over past passes (Time before).\n\n• Compass calibration: figure-eight accuracy meter plus manual heading offset.\n\n• AMSAT status page: consecutive same-status report counts.\n\n• Passes/radar: UTC toggle recalculates passes; sunrise/sunset and elevation color fixes; satellite fuzzy search; mutual page keeps scroll, follows UTC, radar shortcut.\n\n• CW decoder for 32/64-bit devices; Android 17 (API 37) support.</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
@@ -133,6 +131,7 @@
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_grid_mode">Grid mode</string>
<string name="map_satellite_mode">Satellite mode</string>
<string name="map_first_call_mode">First call</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string>
@@ -146,7 +145,7 @@
<string name="map_velocity">Velocity: %.2f km/s</string>
<string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
<string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">First</string>
@@ -274,6 +273,19 @@
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_compass_calibration_button">Calibrate compass</string>
<string name="prefs_compass_calibration_title">Compass calibration</string>
<string name="prefs_compass_calibration_instruction">Move the phone slowly in a figure-eight until accuracy improves. Keep it away from magnets and metal.</string>
<string name="prefs_compass_accuracy">Accuracy: %s</string>
<string name="prefs_compass_accuracy_high">High — calibration complete</string>
<string name="prefs_compass_accuracy_medium">Medium — keep moving</string>
<string name="prefs_compass_accuracy_low">Low — keep moving</string>
<string name="prefs_compass_accuracy_unreliable">Unreliable — start the figure-eight motion</string>
<string name="prefs_compass_accuracy_unavailable">Magnetic sensor unavailable</string>
<string name="prefs_compass_heading">Corrected heading: %.1f°</string>
<string name="prefs_compass_offset">Manual heading offset (°)</string>
<string name="prefs_compass_offset_support">Range −180° to +180°. Positive values rotate clockwise.</string>
<string name="prefs_compass_offset_reset">Reset offset</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
@@ -52,6 +52,13 @@ class MaidenheadGridOverlay : Overlay() {
color = Color.argb(220, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
// 首通呼号标签: 比 4 字符网格代码略小, 以容纳更长的呼号(如 BG2GQG).
private val firstCallPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 20f
style = android.graphics.Paint.Style.FILL
color = Color.argb(230, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
@@ -79,6 +86,15 @@ class MaidenheadGridOverlay : Overlay() {
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
/** Grid-mode first-call labels: label worked (green) cells with the first
* callsign worked in that grid instead of the Maidenhead code; non-worked
* cells get no label at all. Only meaningful at sub-square zoom. */
var showFirstCallLabels: Boolean = false
/** Worked grid -> first callsign worked in it (earliest QSO by time).
* Only read when [showFirstCallLabels] is on. */
var firstCallsByGrid: Map<String, String> = emptyMap()
/**
* Gridsquares the station operated from (4-char, uppercase) — drawn with
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}.
@@ -348,7 +364,19 @@ class MaidenheadGridOverlay : Overlay() {
// Field (2-char) labels show at every zoom, subject only to the pixel-
// size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB
// (one level above the grid lines, so zoom 6 shows lines but no names).
val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
// First-call labels replace the 4-char grid codes and only exist at
// sub-square zoom; at field zoom NO labels are drawn at all (the user
// requirement is that non-worked cells carry no grid characters).
// Show as soon as the 4-char grid LINES appear (GRID_ZOOM_SUB), not
// only at the name-label zoom — user req 2026-09-21.
if (showFirstCallLabels && zoom < GRID_ZOOM_SUB) return
// First-call labels gate on the grid-LINE zoom; normal labels keep the
// name-label zoom (one level above the lines).
val showLabels = if (showFirstCallLabels) {
zoom >= GRID_ZOOM_SUB
} else {
zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
}
if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom:
// project two points 1° apart in latitude and measure the pixel distance.
@@ -356,10 +384,15 @@ class MaidenheadGridOverlay : Overlay() {
val y2 = projectionToY(projection, 1.0)
if (y1 == null || y2 == null) return
val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
// First-call labels must appear as soon as the 4-char grid LINES do
// (zoom 6.0), where 1° cells are ~45 px on the equator — below the
// 48 px normal-label threshold. Relax the pixel gate for this mode only.
val minCellPx = if (showFirstCallLabels) MIN_LABEL_CELL_PX * 0.6f else MIN_LABEL_CELL_PX
if (pixelsPerDegree * cellLat < minCellPx) return
labelPaint.textAlign = Paint.Align.CENTER
val fontMetrics = labelPaint.fontMetrics
val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
activePaint.textAlign = Paint.Align.CENTER
val fontMetrics = activePaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
for (row in firstRow..lastRow) {
val lat = row * cellLat
@@ -386,7 +419,17 @@ class MaidenheadGridOverlay : Overlay() {
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
if (showFirstCallLabels) {
// 首通呼号模式: 只有绿格(worked)标注该格第一个通联的呼号,
// 非绿格空着不写网格字符.
if (label in workedGrids) {
firstCallsByGrid[label]?.let { call ->
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
}
} else {
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
}
}
}
}
@@ -215,6 +215,13 @@ private fun MapScreen(
}
m
}
// First callsign worked in each grid (earliest QSO by time), used by the
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
}.toMap()
}
// Selected operated grid for VUCC counting; defaults to the grid with the
// most worked grids. Null when the store carries no per-QSO myGrid data
// (requires a LoTW resync) — then VUCC falls back to the global count.
@@ -337,7 +344,10 @@ private fun MapScreen(
setGridMode(
uiState.isGridMode, workedGrids, uiState.roamedGrids, view,
uiState.stationPosition,
centerOnStation = shouldCenter
centerOnStation = shouldCenter,
// 首通呼号标签只作用于 VUCC 视图(用户要求); "All"/其他奖状不过滤.
showFirstCallLabels = uiState.showFirstCallLabels && selectedAward == AwardType.VUCC,
firstCallsByGrid = firstCallsByGrid
)
if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode
@@ -372,13 +382,27 @@ private fun MapScreen(
.padding(8.dp)
)
}
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
// Top-right: grid-mode pill + (grid mode only) the first-call
// label pill, stacked vertically.
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.End
) {
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) }
)
// 首通呼号开关仅 VUCC 视图显示(与 VuccGridSelector 同条件).
if (uiState.isGridMode && selectedAward == AwardType.VUCC) {
FirstCallToggle(
checked = uiState.showFirstCallLabels,
onToggle = { onAction(MapAction.ToggleFirstCallLabels(it)) }
)
}
}
}
}
}
@@ -800,6 +824,40 @@ private fun GridModeToggle(
}
}
/**
* 网格模式下的"首通呼号"开关: 打开后绿格(worked)的标签显示该格第一个通联的
* 呼号, 非绿格不显示任何文字. 仅在网格模式下显示(与网格模式开关叠放).
*/
@Composable
private fun FirstCallToggle(
checked: Boolean,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = ComposeColor.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(percent = 50),
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.map_first_call_mode),
color = ComposeColor.White,
fontSize = 12.sp,
modifier = Modifier.padding(end = 2.dp)
)
Switch(
checked = checked,
onCheckedChange = onToggle,
modifier = Modifier.scale(0.75f)
)
}
}
}
// endregion
// region Map overlay helpers
@@ -843,7 +901,9 @@ private fun setGridMode(
roamedGrids: Set<String>,
mapView: MapView,
stationPosition: GeoPos?,
centerOnStation: Boolean = false
centerOnStation: Boolean = false,
showFirstCallLabels: Boolean = false,
firstCallsByGrid: Map<String, String> = emptyMap()
) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
@@ -851,6 +911,9 @@ private fun setGridMode(
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
gridOverlay.roamedGrids = roamedGrids
// 首通呼号模式: 绿格标注第一个通联的呼号, 非绿格不显示标签.
gridOverlay.showFirstCallLabels = showFirstCallLabels
gridOverlay.firstCallsByGrid = firstCallsByGrid
// ownGrid must be set on EVERY update — the position is available
// regardless of whether this frame centers (centering happens only
// on entry, but passing null here would wipe the bold outline).
@@ -860,6 +923,8 @@ private fun setGridMode(
isEnabled = gridMode
this.workedGrids = workedGrids
this.roamedGrids = roamedGrids
this.showFirstCallLabels = showFirstCallLabels
this.firstCallsByGrid = firstCallsByGrid
this.ownGrid = ownGridOf(stationPosition)
}
}
@@ -27,6 +27,9 @@ data class MapState(
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val isGridMode: Boolean = false,
/** Grid mode: label worked (green) cells with the first callsign worked in
* that grid instead of the grid code; non-worked cells get no label. */
val showFirstCallLabels: Boolean = false,
val workedGrids: Set<String> = emptySet(),
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
@@ -48,6 +51,7 @@ sealed interface MapAction {
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
@@ -79,7 +79,13 @@ class MapViewModel(
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
isGridMode = settings.stateOfMapGrid,
showFirstCallLabels = settings.stateOfMapFirstCall
)
}
}
}
viewModelScope.launch {
@@ -105,6 +111,7 @@ class MapViewModel(
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) }
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
@@ -73,6 +73,7 @@ private val allModes = listOf(
)
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
private val historyHourSteps = listOf(0) + hourSteps
private const val dayMinutes = 24 * 60
private const val minuteStep = 15
private const val endOfDayMinute = dayMinutes - 1
@@ -80,6 +81,7 @@ private const val quarterHourSlots = dayMinutes / minuteStep
data class PassFilterParams(
val hours: Int,
val hoursBefore: Int,
val elevation: Double,
val lowElevation: Double,
val highElevation: Double,
@@ -95,6 +97,7 @@ private fun PassesDialogPreview() {
MainTheme {
PassesFilterDialog(
hours = 24,
hoursBefore = 0,
elevation = 16.0,
lowElevation = 16.0,
highElevation = 65.0,
@@ -111,6 +114,7 @@ private fun PassesDialogPreview() {
@Composable
internal fun PassesFilterDialog(
hours: Int,
hoursBefore: Int,
elevation: Double,
lowElevation: Double,
highElevation: Double,
@@ -122,6 +126,9 @@ internal fun PassesFilterDialog(
accept: (PassFilterParams) -> Unit
) {
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val hoursBeforeIndex = remember {
mutableIntStateOf(historyHourSteps.indexOfFirst { it >= hoursBefore }.coerceAtLeast(0))
}
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val highlightBounds = 0f..90f
var highlightRange by remember(lowElevation, highElevation) {
@@ -136,6 +143,7 @@ internal fun PassesFilterDialog(
accept(
PassFilterParams(
hours = hourSteps[hoursIndex.intValue],
hoursBefore = historyHourSteps[hoursBeforeIndex.intValue],
elevation = elevationValueNew.doubleValue,
lowElevation = highlightRange.start.roundToInt().toDouble(),
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
@@ -164,6 +172,14 @@ internal fun PassesFilterDialog(
valueRange = 0f..(hourSteps.size - 1).toFloat(),
steps = hourSteps.size - 2
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
SliderRow(
title = stringResource(R.string.pass_filter_history_hours),
value = hoursBeforeIndex.intValue.toDouble(),
displayValue = formatHoursLabel(historyHourSteps[hoursBeforeIndex.intValue]),
valueResId = R.drawable.ic_clock,
valueRange = 0f..(historyHourSteps.size - 1).toFloat(),
steps = historyHourSteps.size - 2
) { hoursBeforeIndex.intValue = it.toInt().coerceIn(0, historyHourSteps.size - 1) }
ToggleRow(
title = stringResource(R.string.pass_filter_deep_space),
checked = deepSpaceEnabled,
@@ -103,6 +103,7 @@ private fun PassesScreen(
if (uiState.isPassesDialogShown) {
PassesFilterDialog(
hours = uiState.hours,
hoursBefore = uiState.hoursBefore,
elevation = uiState.elevation,
lowElevation = uiState.lowElevation,
highElevation = uiState.highElevation,
@@ -115,6 +116,7 @@ private fun PassesScreen(
onAction(
PassesAction.FilterPasses(
hoursAhead = params.hours,
hoursBefore = params.hoursBefore,
minElevation = params.elevation,
lowElevation = params.lowElevation,
highElevation = params.highElevation,
@@ -28,6 +28,8 @@ data class PassesState(
val nextTime: String = "00:00:00",
val isNextTimeAos: Boolean = true,
val hours: Int = 24,
/** 历史过境回看窗口(小时), 0 = 只看未来. */
val hoursBefore: Int = 0,
val elevation: Double = 16.0,
val lowElevation: Double = 16.0,
val highElevation: Double = 65.0,
@@ -47,6 +49,7 @@ sealed interface PassesAction {
data object DismissWhatsNew : PassesAction
data class FilterPasses(
val hoursAhead: Int,
val hoursBefore: Int,
val minElevation: Double,
val lowElevation: Double,
val highElevation: Double,
@@ -55,6 +55,7 @@ class PassesViewModel(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead,
hoursBefore = settingsRepo.passesSettings.value.hoursBefore,
elevation = settingsRepo.passesSettings.value.minElevation,
lowElevation = settingsRepo.otherSettings.value.lowElevation,
highElevation = settingsRepo.otherSettings.value.highElevation,
@@ -123,6 +124,7 @@ class PassesViewModel(
is PassesAction.FilterPasses ->
applyFilter(
hoursAhead = action.hoursAhead,
hoursBefore = action.hoursBefore,
minElevation = action.minElevation,
lowElevation = action.lowElevation,
highElevation = action.highElevation,
@@ -135,6 +137,7 @@ class PassesViewModel(
is PassesAction.FilterRadios ->
applyFilter(
hoursAhead = _uiState.value.hours,
hoursBefore = _uiState.value.hoursBefore,
minElevation = _uiState.value.elevation,
lowElevation = _uiState.value.lowElevation,
highElevation = _uiState.value.highElevation,
@@ -246,7 +249,7 @@ class PassesViewModel(
return ordered
}
/** Computes live progress for each pass, filtering out expired ones. */
/** Computes live progress while retaining completed passes in the requested history window. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
for (pass in passList) {
@@ -254,9 +257,9 @@ class PassesViewModel(
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
val boundedProgress = newProgress.coerceIn(0f, 1f)
if (boundedProgress != pass.progress) {
result.add(pass.copy(progress = boundedProgress))
} else {
result.add(pass)
}
@@ -285,6 +288,7 @@ class PassesViewModel(
private fun applyFilter(
hoursAhead: Int,
hoursBefore: Int,
minElevation: Double,
lowElevation: Double,
highElevation: Double,
@@ -302,13 +306,15 @@ class PassesViewModel(
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
modes
modes,
hoursBefore
)
)
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
_uiState.update {
it.copy(
hours = hoursAhead,
hoursBefore = hoursBefore,
elevation = minElevation,
lowElevation = lowElevation,
highElevation = highElevation,
@@ -319,9 +325,10 @@ class PassesViewModel(
modes = modes
)
}
val now = System.currentTimeMillis()
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = hoursAhead,
time = now - hoursBefore * 60L * 60L * 1000L,
hoursAhead = hoursBefore + hoursAhead,
minElevation = minElevation,
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
@@ -82,7 +82,7 @@ class RadarViewModel(
private val _uiState = MutableStateFlow(
RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.sensorData.value,
orientationValues = correctedOrientation(sensorsRepo.sensorData.value),
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
@@ -102,12 +102,19 @@ class RadarViewModel(
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
val orientationValues = correctedOrientation(data)
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
}
/** 指南针方位 = 传感器方位 + 磁偏角 + 手动偏置(compassOffsetDegrees, 校准用). */
private fun correctedOrientation(data: Pair<Float, Float>): Pair<Float, Float> {
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
val azimuth = ((data.first + magDeclination + offset) % 360f + 360f) % 360f
return azimuth to data.second
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
@@ -38,8 +38,10 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
@@ -56,6 +58,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -66,9 +70,11 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
@@ -204,6 +210,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.compassCalibration) {
LaunchedEffect(Unit) { onAction(SettingsAction.StartCompassCalibration) }
CompassCalibrationDialog(
accuracy = uiState.compassAccuracy,
headingDegrees = uiState.compassHeadingDegrees,
initialOffsetDegrees = uiState.otherSettings.compassOffsetDegrees,
onDismiss = {
onAction(SettingsAction.StopCompassCalibration)
dialogs.compassCalibration = false
},
onSave = { onAction(SettingsAction.SetCompassOffset(it)) }
)
}
if (dialogs.lotw) {
// LoTW sync failures are typed codes from the ViewModel; render them
// through string resources so the dialog follows the system language.
@@ -342,7 +361,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
showLoTWDialog = { dialogs.lotw = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item {
OtherCard(
settings = uiState.otherSettings,
onCompassCalibration = { dialogs.compassCalibration = true },
onAction = onAction
)
}
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
CardButton(
@@ -530,15 +555,19 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values) {}
OtherCard(settings = values, onCompassCalibration = {}, onAction = {})
}
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
private fun OtherCard(
settings: OtherSettings,
onCompassCalibration: () -> Unit,
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.height(370.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -560,6 +589,11 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
CardButton(
onClick = onCompassCalibration,
text = stringResource(R.string.prefs_compass_calibration_button),
modifier = Modifier.fillMaxWidth()
)
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
@@ -567,6 +601,94 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
}
}
/**
* 指南针校准对话框: 8 字动作提升精度, 实时显示校正后航向, 可手动输入偏置(±180°).
*/
@Composable
private fun CompassCalibrationDialog(
accuracy: CompassAccuracy,
headingDegrees: Float,
initialOffsetDegrees: Float,
onDismiss: () -> Unit,
onSave: (Float) -> Unit
) {
var offsetText by rememberSaveable(initialOffsetDegrees) {
mutableStateOf(initialOffsetDegrees.toString())
}
val parsedOffset = offsetText.replace(',', '.').toFloatOrNull()?.takeIf { it in -180f..180f }
val statusRes = when (accuracy) {
CompassAccuracy.HIGH -> R.string.prefs_compass_accuracy_high
CompassAccuracy.MEDIUM -> R.string.prefs_compass_accuracy_medium
CompassAccuracy.LOW -> R.string.prefs_compass_accuracy_low
CompassAccuracy.UNRELIABLE -> R.string.prefs_compass_accuracy_unreliable
CompassAccuracy.UNAVAILABLE -> R.string.prefs_compass_accuracy_unavailable
}
val progress = when (accuracy) {
CompassAccuracy.HIGH -> 1f
CompassAccuracy.MEDIUM -> 0.66f
CompassAccuracy.LOW -> 0.33f
CompassAccuracy.UNRELIABLE, CompassAccuracy.UNAVAILABLE -> 0f
}
SharedDialog(
title = stringResource(R.string.prefs_compass_calibration_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = {
parsedOffset?.let {
onSave(it)
onDismiss()
}
}
) { padding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = padding)
) {
Text(
text = "∞",
fontSize = 92.sp,
color = MaterialTheme.colorScheme.primary,
lineHeight = 92.sp
)
Text(
text = stringResource(R.string.prefs_compass_calibration_instruction),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.prefs_compass_accuracy, stringResource(statusRes)),
fontWeight = FontWeight.Medium,
color = if (accuracy == CompassAccuracy.HIGH) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
Text(
text = stringResource(R.string.prefs_compass_heading, headingDegrees),
fontSize = 20.sp,
fontWeight = FontWeight.Medium
)
OutlinedTextField(
value = offsetText,
onValueChange = { offsetText = it },
label = { Text(stringResource(R.string.prefs_compass_offset)) },
supportingText = { Text(stringResource(R.string.prefs_compass_offset_support)) },
isError = parsedOffset == null,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
CardButton(
onClick = { offsetText = "0" },
text = stringResource(R.string.prefs_compass_offset_reset),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
@@ -741,6 +863,7 @@ private class DialogVisibility {
var radioControl by mutableStateOf(false)
var wavelog by mutableStateOf(false)
var lotw by mutableStateOf(false)
var compassCalibration by mutableStateOf(false)
}
@Composable
@@ -748,13 +871,14 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
lotw = it.getOrElse(7) { false }
compassCalibration = it.getOrElse(8) { false }
}
}
)
@@ -24,6 +24,7 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import java.io.File
@@ -67,6 +68,10 @@ data class SettingsState(
val lotwError: LoTWError? = null,
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
val lotwLastSyncEpochMs: Long = 0L,
/** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
val compassHeadingDegrees: Float = 0f,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
@@ -98,6 +103,9 @@ sealed interface SettingsAction {
data class ToggleAutoLotwSync(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data object StartCompassCalibration : SettingsAction
data object StopCompassCalibration : SettingsAction
data class SetCompassOffset(val degrees: Float) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
@@ -25,6 +25,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
@@ -49,6 +50,7 @@ class SettingsViewModel(
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val sensorsRepo: ISensorsRepo,
private val apkFile: File,
private val showToast: IShowToast
) : ViewModel() {
@@ -57,6 +59,8 @@ class SettingsViewModel(
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
/** In-flight LoTW sync job, cancelled by CancelLoTWSync. */
private var lotwSyncJob: kotlinx.coroutines.Job? = null
/** 指南针校准: 传感器原始方位(未加磁偏角/偏置), 校准对话框实时显示校正后航向. */
private var rawCompassHeading = sensorsRepo.sensorData.value.first
private val _uiState = MutableStateFlow(
SettingsState(
appVersionName = settingsRepo.appVersionName,
@@ -69,6 +73,8 @@ class SettingsViewModel(
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(),
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
)
)
@@ -79,7 +85,10 @@ class SettingsViewModel(
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos))
it.copy(
positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos),
compassHeadingDegrees = correctedCompassHeading()
)
}
}
}
@@ -104,7 +113,20 @@ class SettingsViewModel(
}
viewModelScope.launch {
settingsRepo.otherSettings.collect { settings ->
_uiState.update { it.copy(otherSettings = settings) }
_uiState.update {
it.copy(otherSettings = settings, compassHeadingDegrees = correctedCompassHeading())
}
}
}
viewModelScope.launch {
sensorsRepo.compassAccuracy.collect { accuracy ->
_uiState.update { it.copy(compassAccuracy = accuracy) }
}
}
viewModelScope.launch {
sensorsRepo.sensorData.collect { orientation ->
rawCompassHeading = orientation.first
_uiState.update { it.copy(compassHeadingDegrees = correctedCompassHeading()) }
}
}
viewModelScope.launch {
@@ -157,6 +179,11 @@ class SettingsViewModel(
is SettingsAction.ToggleAutoLotwSync -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoLotwSync = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
SettingsAction.StartCompassCalibration -> sensorsRepo.enableSensor()
SettingsAction.StopCompassCalibration -> sensorsRepo.disableSensor()
is SettingsAction.SetCompassOffset -> settingsRepo.updateOtherSettings {
it.copy(compassOffsetDegrees = action.degrees.coerceIn(-180f, 180f))
}
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
// Remote control & data sources
@@ -358,6 +385,18 @@ class SettingsViewModel(
}
}
/** 校正后航向 = 传感器原始方位 + 磁偏角 + 手动偏置. */
private fun correctedCompassHeading(): Float {
val declination = sensorsRepo.getMagDeclination(settingsRepo.stationPosition.value)
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
return ((rawCompassHeading + declination + offset) % 360f + 360f) % 360f
}
override fun onCleared() {
sensorsRepo.disableSensor()
super.onCleared()
}
// endregion
// region Data update helpers — consolidated from 3 near-identical functions
@@ -405,6 +444,7 @@ class SettingsViewModel(
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
lotwRepo = container.lotwRepo,
sensorsRepo = container.provideSensorsRepo(),
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()
)
@@ -338,15 +338,16 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
/**
* Day block: colored by the newest reported status among the day's 12 slots
* (gray when none); the number is the TOTAL report count of the whole day
* (sum over all slots), not the count of the newest slot alone.
* (gray when none); the number is the count of the most recent consecutive
* reports sharing that same status (day.streakCount), not the total report
* count of the whole day.
*/
@Composable
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
val noReportGray = 0xFFC0C0C0L
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
val color = Color(slot.statusColor)
val totalCount = day.slots.sumOf { it.count }
val streakCount = day.streakCount
Box(
modifier = modifier
.height(28.dp)
@@ -355,8 +356,8 @@ private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (totalCount > 0) {
Text(text = totalCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (streakCount > 0) {
Text(text = streakCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
+4 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "480"
appVersionCode = "484"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.10"
appVersionName = "4.4.7-ba7opf.11.3"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -79,6 +79,8 @@ other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "othe
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
test-junit4 = { module = "junit:junit", version.ref = "test-junit4" }
# Real org.json for unit tests (android.jar stub throws "not mocked")
test-json = { module = "org.json:json", version = "20240303" }
androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" }
androidTest-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidTest-espresso" }