Compare commits

..
Author SHA1 Message Date
mckero 691c5f5bb1 feat(status): add AMSAT satellite status tracking
New feature: displays recent AMSAT satellite activity from amsat.org API.
Shows 6-day status grid (Heard/Telemetry/Not Heard) + detailed reports.

Addresses PR #233 feedback:
- Uses Material3 colorScheme (tertiary/tertiaryContainer) for theme support
- HTTP in data layer via RemoteSource (Clean Architecture)
- SimpleDateFormat instead of manual date math
- Edge-to-edge support

Implementation:
- feature:status module (AMSAT status screen + legend)
- core:data AmSatRepository (API client using RemoteSource)
- core:domain models (SatStatus/SatReport/SatSlot/SatDay)
- MainTheme.kt: AMSAT colors mapped to colorScheme slots

Navigation intentionally not included - you mentioned potential UI
restructuring (Navigation Drawer). The screen is ready to integrate
wherever it fits your roadmap.

Ref: https://github.com/rt-bishop/Look4Sat/pull/233
2026-08-05 17:43:17 +00:00
Arty Bishop 8fbfcb3712 v4.4.5 - Pass progress hotfix, swapped modes/filter dialogs 2026-08-05 13:14:50 +02:00
Arty Bishop 602b1553a2 v4.4.4 - Icom CAT, components, filters and sources tweaks 2026-08-04 20:51:22 +02:00
atsunatsuandatsunatsu bd0881fb4b Added linear transponder Doppler calculator (#232)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:45:12 +02:00
atsunatsuandatsunatsu a9dd3e6e55 Localized pass date and time formats for Chinese (#231)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:12:10 +02:00
Lukas 905995ab44 Added configurable Radar offset to the sensors output (#230) 2026-08-04 18:08:44 +02:00
Arty Bishop f9806d7f7f Replaced the types selection dialog with modes selection 2026-07-31 16:04:39 +02:00
Arty Bishop 2d3adfc6a6 Added small tweaks to Sources, Components and strings 2026-07-31 12:27:25 +02:00
97 changed files with 2305 additions and 5605 deletions

No files matched your search

+4
View File
@@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "never"
+40 -19
View File
@@ -4,15 +4,10 @@ on:
push:
tags:
- v**
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag name (e.g. v4.4.3)'
required: false
default: ''
env:
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
TAG_NAME: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
jobs:
release:
@@ -21,40 +16,66 @@ jobs:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Assemble APK
run: ./gradlew assembleRelease
- name: Assemble Artifacts
run: ./gradlew assembleRelease bundleRelease
- name: Sign APK
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-Pro-${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 "$SIGNED_APK" \
--out app/build/outputs/apk/release/look4sat.apk \
"$APK"
rm keystore.jks
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
- name: Sign Bundle
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
- name: Deploy Bundle to Google Play
run: |
gem install multi_json
gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
gh release upload $TAG_NAME "$SIGNED_APK"
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
+2 -4
View File
@@ -1,9 +1,6 @@
# Built application files
*.ap_
# Hermes AI plans and metadata
.hermes/
# Files for the ART/Dalvik VM
*.dex
@@ -39,7 +36,8 @@ captures/
.idea/
# Keystore files
*.jks
# Uncomment the following line if you do not want to check your keystore files in.
#*.jks
/*.properties
/keystore.properties
/app/keystore.jks
+48 -49
View File
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
## Project Overview
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
data download.
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
no tracking, no network required after initial data download.
## Architecture
## Architecture & Design
**MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class → exposed via `StateFlow` from ViewModel
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
@@ -34,9 +33,11 @@ data download.
| `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences |
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
**Feature isolation:**
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
## Build & Run
## Build & Platform
```shell
# Debug build
@@ -50,54 +51,55 @@ data download.
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
## Key Libraries
## Tech Stack
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
- **Room** (KSP code generation) for local satellite/TLE storage
- **OkHttp** 5.x for TLE downloads
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
- **Room** (KSP code generation) for local satellite/orbital storage
- **OkHttp** 5.x for data downloads
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and data parsing
- **Kotlin Serialization** for navigation args and parsing
- **Coroutines** + `StateFlow` for async/reactive patterns
## Conventions
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
## Data Formats & Migration
**TLE vs. OMM/CSV format:**
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
- Downloads auto-detect format; both produce identical `OrbitalData` objects
- Existing code already supports transparent source transition without feature changes
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
## Engineering Heuristics (Lazy = Efficient)
**Current implementation:**
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
- TLE data is downloaded from configured sources and stored in Room database
- When downloading satellite data, the app automatically detects format and parses accordingly
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
- Treat "lazy" as efficient, not careless: the best code is the code never written.
- First understand the task and trace the real flow end-to-end, then climb this ladder:
1. Does this need to be built now? (YAGNI)
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
3. Does Kotlin/Java stdlib already solve it?
4. Does the Android/platform API already solve it?
5. Does an already-installed dependency solve it?
6. Can this be simpler (including one-liner simple)?
7. Only then: write the minimum code that works.
- Prefer deletion to addition, boring over clever, and the fewest touched files.
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
- When two options are similar in size, choose the edge-case-correct one.
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
**Migration path:**
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
to handle this transition without code changes — existing users can continue using TLE files while new sources
transition to OMM/CSV automatically.
## Bug-Fix Policy
## Code Style
- Prefer **short, focused functions** — single responsibility, easy to read.
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
- Strict code style — no dead code, no unused imports, consistent formatting.
- Fix root cause, not just the reported symptom.
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
- The smallest correct diff wins only after behavior is understood.
## Roadmap
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
## Gotchas
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
## Copilot Working Mode: Code-Only
-26
View File
@@ -1,29 +1,3 @@
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Load signing config from keystore.properties (gitignored, never commit credentials)
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
}
android {
signingConfigs {
if (keystoreProperties["storeFile"] != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
}
}
}
+1 -2
View File
@@ -25,7 +25,6 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Look4Sat.SplashScreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -43,7 +42,7 @@
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat.bg7nta" />
android:value="com.rtbishop.look4sat" />
</application>
</manifest>
@@ -63,16 +63,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -81,11 +78,8 @@ import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.mutual.MutualScreen
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.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@@ -131,17 +125,12 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.Map, Screen.Settings)
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MutualViewModel.factory(container)
)
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
@@ -156,7 +145,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -202,7 +190,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
@@ -214,20 +201,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Mutual> {
MutualScreen(
viewModel = mutualViewModel,
navigateUp = navigateBack,
navigateToRadar = { catNum, aosTime, pass ->
container.setMutualPassData(pass ?: MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
}
)
}
entry<Screen.Roaming> {
RoamingScreen()
}
entry<Screen.Settings> {
SettingsDestination()
}
@@ -251,7 +224,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar)
}
@@ -32,10 +32,8 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:roaming"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(libs.androidx.core.splashscreen)
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = libs.versions.applicationId.get()
applicationId = libs.versions.packageName.get()
minSdk = libs.versions.minSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
@@ -45,7 +45,12 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query(
"""
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
"""
)
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -50,7 +51,6 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
@@ -62,32 +62,24 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource = provideRemoteSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
}
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context)
@@ -146,6 +138,13 @@ class MainContainer(private val context: Context) : IMainContainer {
return SensorsRepo(manager, displayManager)
}
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
} catch (_: SecurityException) {}
}
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
@@ -0,0 +1,156 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (e: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (e: Exception) {
0L
}
}
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 6).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 72).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 6).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
}
companion object {
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -47,7 +47,6 @@ class DatabaseRepo(
remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -69,24 +68,18 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently and associate with types
// parse fetched data concurrently
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
}
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -73,7 +74,7 @@ class SatelliteRepo(
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
modes = settingsRepo.selectedSatModes.value
)
}
}
@@ -95,8 +96,14 @@ class SatelliteRepo(
}
}
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
override suspend fun getRadios(
sat: OrbitalObject,
pos: GeoPos,
radios: List<SatRadio>,
time: Long
): List<SatRadio> {
return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
radios.map { transmitter ->
transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
@@ -152,7 +159,7 @@ class SatelliteRepo(
}
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
delay(1000.milliseconds) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
@@ -170,7 +177,7 @@ class SatelliteRepo(
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
@@ -236,16 +243,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine AOS to ~500ms precision via binary search.
// Elevation is monotonic across the horizon crossing, so binary search
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
while (aosHi - aosLo > 500L) {
val mid = (aosLo + aosHi) / 2
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
}
calendarTimeMillis = aosHi
// refine AOS to ~500ms precision
calendarTimeMillis -= 60L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// Get full position for AOS data (azimuth, altitude)
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -259,14 +263,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine LOS to ~500ms precision via binary search (same monotonic argument)
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
while (losHi - losLo > 500L) {
val mid = (losLo + losHi) / 2
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
}
calendarTimeMillis = losHi
// refine LOS to ~500ms precision
calendarTimeMillis -= 30L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// Get full position for LOS data (azimuth, altitude)
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively.
// Resolve sat IDs once when modes change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
val ids = localSource.getIdsWithModes(list)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -56,14 +56,18 @@ class SelectionRepo(
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
itemsWithModes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
}
override fun getCurrentTypes() = currentTypes.value
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
}
override fun getModesList() = Sources.satelliteModes
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -73,9 +77,8 @@ class SelectionRepo(
return@withContext itemsWithQuery
}
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
override suspend fun setModes(modes: List<String>) {
settingsRepo.setSelectedSatModes(modes)
}
override suspend fun setQuery(query: String) {
@@ -29,7 +29,6 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -69,8 +68,7 @@ class SettingsRepo(
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
@@ -88,6 +86,8 @@ class SettingsRepo(
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
@@ -96,9 +96,9 @@ class SettingsRepo(
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma)
@@ -106,10 +106,10 @@ class SettingsRepo(
_satelliteSelection.value = ids
}
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
override fun setSelectedSatModes(modes: List<String>) {
val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedSatModes, modesString) }
_satelliteModeSelection.value = modes
}
private fun getSelectedIds(): List<Int> {
@@ -118,10 +118,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma)
private fun getSelectedSatModes(): List<String> {
val modesString = preferences.getString(keySelectedSatModes, null)
if (modesString.isNullOrEmpty()) return emptyList()
return modesString.split(separatorComma).sorted()
}
//endregion
@@ -136,7 +136,6 @@ class SettingsRepo(
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
@@ -147,16 +146,13 @@ class SettingsRepo(
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
invertAosTimeWindow
)
}
//endregion
@@ -214,8 +210,8 @@ class SettingsRepo(
}
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(5)
val newLon = longitude.round(5)
val newLat = latitude.round(4)
val newLon = longitude.round(4)
val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
@@ -236,25 +232,6 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
val typeString = preferences.getString("type$type", null)
val typeIds = if (typeString.isNullOrBlank()) {
emptyList()
} else {
typeString.split(separatorComma).map { it.toInt() }
}
idsSet.addAll(typeIds)
}
return idsSet.toList()
}
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
if (type == "All") return
val typesString = ids.joinToString(separatorComma)
preferences.edit { putString("type$type", typesString) }
}
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
putInt(keyNumberOfSatellites, state.numberOfSatellites)
@@ -357,6 +334,8 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
}
new
}
@@ -373,7 +352,9 @@ class SettingsRepo(
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
)
//endregion
@@ -391,20 +372,12 @@ class SettingsRepo(
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
return DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
tleUrl = tleUrl,
transceiversUrl = txUrl
)
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
)
//endregion
//region # Radio control settings
@@ -24,7 +24,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.InputStream
class RemoteSource(
@@ -46,13 +45,49 @@ class RemoteSource(
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
}
// Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool
response.body.byteStream().buffered()
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
null
}
}
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
}
@@ -62,7 +62,6 @@ class DatabaseRepoTest {
assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum)
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
}
@@ -102,8 +101,6 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
private fun validCsvStream(): InputStream = """
@@ -166,10 +163,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
@@ -190,11 +187,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: List<String>) = Unit
override fun setSelectedSatModes(modes: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
@@ -204,11 +199,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
satelliteTypeIdsByType[type] = ids
}
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
@@ -0,0 +1,173 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(40967, "TIANGONG", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setModes(listOf("REMOVED_MODE"))
val items = flow.first()
assertEquals(listOf(25544, 40967), items.map { it.catnum })
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
}
@Test
fun `selected satellites are shown first`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(44444, "Zeta", false),
SatItem(25544, "Alpha", false),
SatItem(40967, "Beta", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setSelection(listOf(40967), true)
val items = flow.first()
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
assertEquals(listOf(true, false, false), items.map { it.isSelected })
}
private class FakeLocalSource(
private val entries: List<SatItem>
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = entries.size
override suspend fun getEntriesList(): List<SatItem> = entries
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepo(
selectedModes: List<String>
) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(false, false, "", "")
)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) {
selectedSatModes.value = modes
}
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
}
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
}
}
@@ -1,154 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Bayesian Morse timing decoder.
* Replaces hard thresholds with probability-based decision making.
*
* Inspired by VE3NEA's CW Skimmer approach:
* "Instead of making a hard decision at every input sample whether the signal
* is present or not, compute the probability that the signal is present."
*
* Uses Gaussian probability density centered on expected durations:
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
*/
internal class CwBayesianDecoder {
// Morse timing parameters
private var dotDurationMs = 60f // initial 20 WPM
private var speedWpm = 20f
// Current symbol being accumulated
private var currentSymbol = StringBuilder()
private var textBuffer = StringBuilder()
// Recent dit lengths for speed estimation
private val recentDits = mutableListOf<Float>()
// Output
private var _decodedText = ""
val decodedText: String get() = _decodedText
/** Gaussian probability. */
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
if (varianceMs <= 0f) return 0f
val diff = durationMs - expectedMs
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
}
/** Process a tone duration. Returns the symbol type with highest probability. */
fun processTone(durationMs: Float): ToneResult {
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
return if (ditProb > dashProb && ditProb > 0.05f) {
currentSymbol.append('0')
recentDits.add(durationMs)
updateSpeed()
ToneResult('0', ditProb)
} else if (dashProb > 0.05f) {
currentSymbol.append('1')
ToneResult('1', dashProb)
} else {
ToneResult(null, 0f)
}
}
/** Process a gap duration. Returns decoded character or null. */
fun processGap(durationMs: Float): Char? {
if (currentSymbol.isEmpty()) {
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > 0.2f) {
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return ' '
}
return null
}
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > interCharProb && wordProb > 0.2f) {
val char = flushSymbol()
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return char
}
if (interCharProb > 0.15f) {
val char = flushSymbol()
_decodedText = textBuffer.toString()
return char
}
return null
}
private fun flushSymbol(): Char? {
if (currentSymbol.isEmpty()) return null
val morse = currentSymbol.toString()
currentSymbol.clear()
val char = morseToChar(morse)
if (char != null) textBuffer.append(char)
return char
}
private fun updateSpeed() {
if (recentDits.size < 3) return
val sorted = recentDits.sorted()
val median = sorted[sorted.size / 2]
if (median > 0f) {
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
if (wpm in 5f..55f) speedWpm = wpm
}
}
fun getSpeed(): Float = speedWpm
fun reset() {
dotDurationMs = 60f
speedWpm = 20f
recentDits.clear()
currentSymbol.clear()
textBuffer.clear()
_decodedText = ""
}
companion object {
private val MORSE_TABLE = mapOf(
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
"11100" to '8', "11110" to '9', "11111" to '0',
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
"0001001" to '$', "011010" to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
data class ToneResult(val symbol: Char?, val probability: Float)
@@ -1,114 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Multi-channel CW signal tracker.
* Monitors the spectrogram for active frequency bins and extracts
* energy envelopes for each detected signal.
*
* Inspired by CW Skimmer's multi-channel approach:
* tracks all active signals in the passband simultaneously,
* selects the best one for decoded output.
*/
internal class CwChannelTracker(
private val spectrogram: CwSpectrogram,
private val maxChannels: Int = 3
) {
data class Channel(
val bin: Int,
val frequency: Float,
var active: Boolean = false,
var energy: Float = 0f,
val history: MutableList<Float> = mutableListOf(),
var confidence: Float = 0f
)
private val channels = Array(maxChannels) { Channel(0, 0f) }
/** Scan the current spectrogram column and update channel tracking. */
fun update(): List<Channel> {
val col = spectrogram.getCurrentColumn()
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
// Update existing channels
for (ch in channels) {
if (ch.active) {
if (peaks.contains(ch.bin)) {
ch.energy = col[ch.bin]
ch.history.add(ch.energy)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence = computeConfidence(ch.history)
} else {
// Signal lost — decay confidence
ch.history.add(0f)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence *= 0.9f
if (ch.confidence < 0.1f) ch.active = false
}
}
}
// Assign new peaks to inactive channels
var peakIdx = 0
for (ch in channels) {
if (!ch.active && peakIdx < peaks.size) {
val bin = peaks[peakIdx]
val freq = spectrogram.binToFreq(bin)
// Re-initialize channel
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
peakIdx++
}
}
return channels.filter { it.active }
}
/** Find peak bins in the spectrum. */
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
val peaks = mutableListOf<Int>()
for (i in 1 until spectrum.size - 1) {
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
peaks.add(i)
}
}
}
return peaks.sortedByDescending { spectrum[it] }
}
/** Compute confidence from energy history. Lower variance = higher confidence. */
private fun computeConfidence(history: List<Float>): Float {
if (history.size < 10) return 0.3f
val recent = history.takeLast(10)
val mean = recent.average().toFloat()
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
}
/** Get the channel with highest confidence. */
fun getBestChannel(): Channel? {
return channels.filter { it.active }.maxByOrNull { it.confidence }
}
fun reset() {
for (i in channels.indices) {
channels[i] = Channel(0, 0f)
}
}
}
@@ -1,165 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
*
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
* 1. FFT spectrogram creates a frequency×time matrix
* 2. Multi-channel peak detector finds all active signals
* 3. Per-channel energy envelope extraction
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
* 5. Best channel selected for output
*
* Timing analysis is performed per spectrogram column (hop).
* Each column represents hopSize/sampleRate seconds of audio.
*/
class CwDecoder(
val sampleRate: Int = 8000,
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
) {
companion object {
private const val FFT_SIZE = 256
private const val HOP_SIZE = 64
}
private val spectrogram = CwSpectrogram(
fftSize = FFT_SIZE,
hopSize = HOP_SIZE,
sampleRate = sampleRate,
minBin = 6,
maxBin = 38,
historyCols = 40
)
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
private val bayesianDecoder = CwBayesianDecoder()
// Timing state per channel
private data class ChannelTiming(
var isSignal: Boolean = false,
var toneTicks: Int = 0,
var gapTicks: Int = 0
)
private val timingStates = Array(3) { ChannelTiming() }
// Time per spectrogram column in milliseconds
private val tickMs = 1000f * HOP_SIZE / sampleRate
// Output flows
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
private val _estimatedPitch = MutableStateFlow<Float?>(null)
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
private var frameCount = 0
init {
if (cwToneFreq > 0f) {
_estimatedPitch.value = cwToneFreq
}
}
fun processBuffer(buffer: FloatArray) {
// 1. Feed samples to spectrogram
spectrogram.addSamples(buffer)
// 2. Get number of new columns generated
val newCols = spectrogram.getNewColumns()
if (newCols == 0) return
// 3. Update channel tracker (uses latest column for peak detection)
val activeChannels = channelTracker.update()
// 4. Process each new column for timing analysis
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
for (colOffset in 0 until newCols) {
val col = spectrogram.getColumn(baseIdx + colOffset)
for ((idx, channel) in activeChannels.withIndex()) {
if (idx >= timingStates.size) break
val state = timingStates[idx]
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
// Adaptive threshold
val threshold = 0.3f + (energy - 0.3f) * 0.3f
if (energy > threshold) {
if (!state.isSignal) {
if (state.gapTicks > 0) {
val gapMs = state.gapTicks * tickMs
bayesianDecoder.processGap(gapMs)
}
state.gapTicks = 0
state.isSignal = true
}
state.toneTicks++
} else {
if (state.isSignal) {
if (state.toneTicks > 0) {
val toneMs = state.toneTicks * tickMs
bayesianDecoder.processTone(toneMs)
}
state.toneTicks = 0
state.isSignal = false
}
state.gapTicks++
}
}
}
// 5. Update outputs
frameCount++
if (frameCount % 5 == 0) {
val bestChannel = channelTracker.getBestChannel()
if (bestChannel != null) {
_estimatedPitch.value = bestChannel.frequency
_signalStrength.value = bestChannel.confidence
_estimatedSpeed.value = bayesianDecoder.getSpeed()
}
_decodedTextFlow.value = bayesianDecoder.decodedText
}
}
fun resetDecoder() {
spectrogram.reset()
channelTracker.reset()
bayesianDecoder.reset()
for (state in timingStates) {
state.isSignal = false
state.toneTicks = 0
state.gapTicks = 0
}
frameCount = 0
_decodedTextFlow.value = ""
_signalStrength.value = 0f
_estimatedPitch.value = null
_estimatedSpeed.value = null
}
}
@@ -1,102 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* DSP utilities for CW (Morse code) decoding.
* Pure Kotlin, no NDK required.
*/
internal object CwDsp {
/**
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
* @param taps filter length (must be odd)
*/
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
val n = if (taps % 2 == 0) taps + 1 else taps
val half = n / 2
val coeffs = FloatArray(n)
for (i in 0 until n) {
val idx = i - half
if (idx == 0) {
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
} else {
val x = PI * idx
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
}
// Hamming window
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
}
// Normalize
val sum = coeffs.sum()
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
return coeffs
}
/** Apply FIR filter to a buffer. */
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
val out = FloatArray(buffer.size)
for (i in buffer.indices) {
var sum = 0f
for (j in coeffs.indices) {
val idx = i - j
if (idx >= 0) sum += buffer[idx] * coeffs[j]
}
out[i] = sum
}
return out
}
/** Simple envelope detector: abs + low-pass smoothing. */
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
val env = FloatArray(signal.size)
var s = 0f
for (i in signal.indices) {
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
env[i] = s
}
return env
}
/** Estimate noise floor from envelope for adaptive thresholding. */
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
val sorted = env.sortedArray()
val median = sorted[sorted.size / 2]
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
}
/** Simple Goertzel to detect a specific tone frequency. */
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
val omega = 2.0 * PI * targetFreq / sampleRate
val coeff = 2.0 * cos(omega)
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
for (sample in buffer) {
s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1; s1 = s0
}
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
return sqrt(kotlin.math.abs(power)).toFloat()
}
}
@@ -1,87 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Radix-2 FFT for real-valued input.
* Produces magnitude spectrum for the first N/2+1 bins.
* Used by CwSpectrogram for time-frequency analysis.
*/
internal class CwFFT(private val n: Int) {
init {
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
}
private val cosTable = FloatArray(n / 2)
private val sinTable = FloatArray(n / 2)
init {
for (i in 0 until n / 2) {
val angle = -2.0 * kotlin.math.PI * i / n
cosTable[i] = cos(angle).toFloat()
sinTable[i] = kotlin.math.sin(angle).toFloat()
}
}
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
fun magnitudeSpectrum(input: FloatArray): FloatArray {
require(input.size == n) { "Input size must be $n, got ${input.size}" }
val real = input.copyOf()
val imag = FloatArray(n)
// Bit-reversal permutation
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
}
}
// Radix-2 Cooley-Tukey FFT
var len = 2
while (len <= n) {
val half = len / 2
val step = n / len
for (i in 0 until n step len) {
for (k in 0 until half) {
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
real[i + k + half] = real[i + k] - tReal
imag[i + k + half] = imag[i + k] - tImag
real[i + k] += tReal
imag[i + k] += tImag
}
}
len = len shl 1
}
// Magnitude spectrum (first N/2+1 bins)
val mag = FloatArray(n / 2 + 1)
for (i in 0..n / 2) {
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
}
return mag
}
}
@@ -1,48 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* First-order IIR filters.
* Ported from ggmorse/src/filter.h
*/
internal class CwFilter {
private var z1 = 0f
companion object {
private const val PI_F = 3.141592653589793f
}
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 = alpha * (z1 + sample - z1)
return sample - z1
}
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 += alpha * (sample - z1)
return z1
}
fun reset() { z1 = 0f }
}
@@ -1,54 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Running Goertzel filter for CW tone detection.
* Tracks a specific frequency over time with a sliding window.
* Ported from ggmorse/src/goertzel.h
*/
internal class CwGoertzel {
private var s1 = 0.0
private var s2 = 0.0
private var coeff = 0.0
fun init(sampleRate: Float, targetFreq: Float) {
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
coeff = 2.0 * cos(omega)
s1 = 0.0
s2 = 0.0
}
fun process(sample: Float) {
val s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1
s1 = s0
}
fun getPower(): Float {
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
}
fun reset() {
s1 = 0.0
s2 = 0.0
}
}
@@ -1,53 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Simple linear resampler.
* Downsamples from input sample rate to output sample rate.
* Ported from ggmorse/src/resampler.h
*/
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
private val ratio = inputRate / outputRate
private var lastSample = 0f
fun process(input: FloatArray): FloatArray {
if (ratio <= 0f || input.isEmpty()) return input
val outputLen = (input.size / ratio).toInt() + 1
val output = FloatArray(outputLen)
var idx = 0f
for (i in output.indices) {
val intIdx = idx.toInt()
val frac = idx - intIdx
if (intIdx + 1 < input.size) {
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
} else if (intIdx < input.size) {
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
} else {
output[i] = lastSample
}
idx += ratio
}
lastSample = input.lastOrNull() ?: lastSample
return output
}
fun reset() {
lastSample = 0f
}
}
@@ -1,61 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Lightweight pitch detector using DFT at specific frequency bins.
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
*/
internal class CwPitchDetector(
private val sampleRate: Float,
private val minFreq: Float = 200f,
private val maxFreq: Float = 1200f,
private val stepHz: Float = 10f
) {
/**
* Find the dominant pitch frequency in the buffer.
* Returns null if no significant pitch found.
*/
fun findPitch(buffer: FloatArray): Float? {
if (buffer.isEmpty()) return null
var bestFreq = 0f
var bestPower = 0f
var freq = minFreq
while (freq <= maxFreq) {
var real = 0.0
var imag = 0.0
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
for (i in buffer.indices) {
real += buffer[i] * cos(omega * i)
imag += buffer[i] * -sin(omega * i)
}
val power = (real * real + imag * imag).toFloat()
if (power > bestPower) {
bestPower = power
bestFreq = freq
}
freq += stepHz
}
return if (bestPower > 0.001f) bestFreq else null
}
}
@@ -1,170 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Sliding-window spectrogram for CW decoding.
* Maintains a time-frequency matrix updated with each audio frame.
*
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
* History: 40 columns (320 ms window)
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
*/
internal class CwSpectrogram(
private val fftSize: Int = 256,
private val hopSize: Int = 64,
private val sampleRate: Int = 4000,
private val minBin: Int = 6,
private val maxBin: Int = 38,
val historyCols: Int = 40
) {
private val fft = CwFFT(fftSize)
val numBins: Int get() = maxBin - minBin + 1
// Hanning window
private val hanning = FloatArray(fftSize) {
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
}
// Spectrogram data: [timeCol][freqBin]
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
private var currentCol = 0
private var samplesBuffered = 0
private val buffer = FloatArray(fftSize)
// Per-bin running energy for normalization
private val binEnergy = FloatArray(numBins) { 1f }
private val alpha = 0.95f
// Counter for new columns generated since last check
private var newColumnCount = 0
/** Add audio samples, compute FFTs for each complete hop. */
fun addSamples(samples: FloatArray) {
var offset = 0
while (offset < samples.size) {
val needed = fftSize - samplesBuffered
val copyLen = minOf(needed, samples.size - offset)
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
samplesBuffered += copyLen
offset += copyLen
if (samplesBuffered >= fftSize) {
processFrame()
newColumnCount++
// Shift buffer: keep last (fftSize - hopSize) samples
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
samplesBuffered = fftSize - hopSize
}
}
}
/** Get number of new columns generated since the last call to this method. */
fun getNewColumns(): Int {
val count = newColumnCount
newColumnCount = 0
return count
}
private fun processFrame() {
// Apply Hanning window
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
// Compute FFT magnitude spectrum
val mag = fft.magnitudeSpectrum(windowed)
// Update spectrogram column
val col = spectrogram[currentCol]
for (b in 0 until numBins) {
val binIdx = minBin + b
val rawMag = mag[binIdx]
// Running energy normalization
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
}
currentCol = (currentCol + 1) % historyCols
}
/** Get the current spectrogram as a 2D array in chronological order. */
fun getSpectrogram(): Array<FloatArray> {
val result = Array(historyCols) { i ->
val srcIdx = (currentCol + i) % historyCols
spectrogram[srcIdx].copyOf()
}
return result
}
/** Get the most recent column (current energy across all frequencies). */
fun getCurrentColumn(): FloatArray {
val prevCol = (currentCol - 1 + historyCols) % historyCols
return spectrogram[prevCol].copyOf()
}
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
fun getColumn(index: Int): FloatArray {
val clamped = index.coerceIn(0, historyCols - 1)
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
return spectrogram[srcIdx].copyOf()
}
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
fun findPeakBin(): Int {
val col = getCurrentColumn()
var maxBin = -1
var maxVal = 0f
for (i in col.indices) {
if (col[i] > maxVal) {
maxVal = col[i]
maxBin = i
}
}
return if (maxVal > 0.3f) maxBin else -1
}
/** Get energy at a specific bin over the last N columns in chronological order. */
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
val clamped = minOf(numCols, historyCols)
val result = FloatArray(clamped)
for (i in 0 until clamped) {
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
result[i] = spectrogram[colIdx][bin]
}
return result
}
/** Get the bin index for a frequency in Hz. */
fun freqToBin(freqHz: Float): Int {
val bin = (freqHz * fftSize / sampleRate).toInt()
return (bin - minBin).coerceIn(0, numBins - 1)
}
/** Get the center frequency for a bin. */
fun binToFreq(bin: Int): Float {
return (minBin + bin).toFloat() * sampleRate / fftSize
}
fun reset() {
for (col in spectrogram) col.fill(0f)
currentCol = 0
samplesBuffered = 0
buffer.fill(0f)
binEnergy.fill(1f)
}
}
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 6 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -29,8 +29,7 @@ data class PassesSettings(
val minElevation: Double,
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
val invertAosTimeWindow: Boolean = false
)
data class RCSettings(
@@ -62,7 +61,9 @@ data class OtherSettings(
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0
val highElevation: Double = 45.0,
val radarCompassOffset: Float = 0f,
val radarCompassOffsetElev: Float = 0f
)
data class DataSourcesSettings(
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
}
@@ -16,13 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
interface IMainContainer {
val appScope: CoroutineScope
@@ -30,9 +29,8 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
@@ -42,27 +40,9 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
fun providePairedBluetoothDevices(): List<Pair<String, String>>
}
data class MutualPassData(
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
val trackSamples: List<TrackSampleData> = emptyList(),
val startTime: Long = 0L,
val endTime: Long = 0L,
val maxElev: Double = 10.0,
val labelA: String = "你",
val labelB: String = "友台"
)
/** Minimal track sample for cross-module sharing (angles in degrees). */
data class TrackSampleData(
val time: Long = 0L,
val azimuthA: Double,
val elevationA: Double,
val azimuthB: Double,
val elevationB: Double
)
interface IContainerProvider {
fun getMainContainer(): IMainContainer
}
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentTypes(): List<String>
fun getTypesList(): List<String>
fun getCurrentModes(): List<String>
fun getModesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setTypes(types: List<String>)
suspend fun setModes(modes: List<String>)
suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
@@ -32,9 +32,9 @@ interface ISettingsRepo {
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
val selectedTypes: StateFlow<List<String>>
val selectedSatModes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>)
fun setSelectedTypes(types: List<String>)
fun setSelectedSatModes(modes: List<String>)
//endregion
//region # Passes filter settings
@@ -51,8 +51,6 @@ interface ISettingsRepo {
//region # Database update settings
val databaseState: StateFlow<DatabaseState>
fun getSatelliteTypesIds(types: List<String>): List<Int>
fun setSatelliteTypeIds(type: String, ids: List<Int>)
fun updateDatabaseState(state: DatabaseState)
//endregion
@@ -22,4 +22,6 @@ import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
}
@@ -18,41 +18,26 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
)
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
)
val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
)
}
@@ -91,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim(),
name = tle[0].trim().removePrefix("0 "),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
@@ -1,4 +1,4 @@
/*
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
@@ -11,18 +11,17 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
*
* For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* Doppler-shifted. Given one, this computes the other:
*
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
*
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/
object DopplerFrequencyCalculator {
@@ -44,6 +43,9 @@ object DopplerFrequencyCalculator {
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
@@ -52,7 +54,7 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
}
@@ -95,4 +97,26 @@ object DopplerFrequencyCalculator {
return upLow != null && upHigh != null && downLow != null && downHigh != null
&& upLow != upHigh && downLow != downHigh
}
/**
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for named linear
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
}
}
@@ -19,78 +19,31 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
* The returned position is the center of the finest cell encoded by the locator:
* - 6 char: 5' lon x 2.5' lat cell center
* - 8 char: 30" lon x 15" lat cell center
* - 10 char: 1.25" lon x 0.625" lat cell center
*/
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.trim().uppercase()
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].code - 65) * 10
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
var longitude = lonFirst + lonSecond + lonThird - 180
var latitude = latFirst + latSecond + latThird - 90
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
if (trimmedQth.length >= 8) {
longitude += trimmedQth[6].toString().toInt() / 120.0
latitude += trimmedQth[7].toString().toInt() / 240.0
}
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
if (trimmedQth.length >= 10) {
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
}
// Offset to the center of the finest encoded cell
when (trimmedQth.length) {
8 -> {
longitude += 1.0 / 240.0
latitude += 1.0 / 480.0
}
10 -> {
longitude += 1.0 / 5760.0
latitude += 1.0 / 11520.0
}
else -> {
longitude += 1.0 / 24.0
latitude += 1.0 / 48.0
}
}
return GeoPos(latitude.round(6), longitude.round(6))
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
}
/**
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
* matching common 8-char grid square tools. Pass precision = 6 for the classic
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
*/
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null
val newLongitude = longitude + 180
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
val newLatitude = latitude + 90
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
val lonSecond = ((newLongitude % 20) / 2).toInt()
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
if (precision < 8) return qth
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
val qth8 = "$qth$lonFourth$latFourth"
if (precision < 10) return qth8
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
return "$qth8$lonFifth$latFifth"
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
@@ -98,49 +51,5 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
}
/**
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
* A 4-char input is returned as-is when valid.
*/
fun qthToSquare(locator: String): String {
val upper = locator.trim().uppercase()
return when {
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
else -> "----"
}
}
/**
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
*/
fun qthNeighbors(square: String): List<String> {
if (square.length != 4) return emptyList()
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
val result = mutableListOf<String>()
for (dLat in 1 downTo -1) { // north -> south
for (dLon in -1..1) { // west -> east
var lf = lonField
var tf = latField
var ls = lonSquare + dLon
var ts = latSquare + dLat
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
lf = (lf + 18) % 18
tf = (tf + 18) % 18
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
}
}
return result
}
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
@@ -3,7 +3,11 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DopplerFrequencyCalculatorTest {
@@ -13,12 +17,15 @@ class DopplerFrequencyCalculatorTest {
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
downHigh: Long? = 435_500_000L,
inverted: Boolean = false
inverted: Boolean = false,
info: String = "Linear Transponder",
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = "linear", info = "Linear Transponder", isAlive = true,
uuid = "linear", info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
)
private fun fmTransponder() = SatRadio(
@@ -48,14 +55,34 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
}
@Test
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertTrue(uplink!! > 0)
// With zero Doppler, result equals mapDownlinkToUplink output
assertEquals(145_200_000L, uplink)
}
@@ -70,42 +97,86 @@ class DopplerFrequencyCalculatorTest {
@Test
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
val xpdr = linearTransponder()
val orbitalPos = pos(7.0) // ~7 km/s receding
val orbitalPos = pos(7.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Uplink freq should be Doppler shifted UP (compensating for receding)
assertTrue(uplink!! > 145_200_000L)
}
@Test
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(435_202_500L, downlink)
}
@Test
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = 435_202_500L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeOffsetRoundTrip_handlesNegativeOffset() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(435_197_500L, downlink)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = downlink!!,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_invertedTransponder() {
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Inverted: offset from high end → maps to high end of uplink
assertEquals(145_300_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_roundTrip() {
// downlink → uplink → downlink should round-trip
val xpdr = linearTransponder()
val orbitalPos = pos(3.5)
val originalDownlink = 435_250_000L
@@ -113,7 +184,6 @@ class DopplerFrequencyCalculatorTest {
assertNotNull(uplink)
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink)
// Doppler round-trip: small residual due to freq-dependent Doppler
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000)
}
@@ -18,9 +18,7 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import org.junit.Test
class QthConverterTest {
@@ -28,36 +26,21 @@ class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
// default precision is 8 chars
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
}
@Test
@@ -65,61 +48,4 @@ class QthConverterTest {
assert(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null)
}
@Test
fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf(
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
)
positions.forEach { (lat, lon) ->
val qth = positionToQth(lat, lon, 8)
val pos = qthToPosition(qth!!)
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
}
}
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH定位器 screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)) { "OL42 grid mismatch: $neighbors" }
// Center cell must be the input itself
assert(neighbors[4] == "OL42")
// 9 cells, all distinct
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
}
@Test
fun `Given boundary square wraps fields correctly`() {
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
val sw = qthNeighbors("AA00")
assert(sw.size == 9 && sw.toSet().size == 9)
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
// North-east corner: RR99 wraps to AA00
val ne = qthNeighbors("RR99")
assert(ne.size == 9 && ne.toSet().size == 9)
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
// Field boundary: IO91's east neighbors cross into J field
val london = qthNeighbors("IO91")
assert(london[2] == "JO02" && london[5] == "JO01")
}
@Test
fun `Given full locator returns square part`() {
assert(qthToSquare("OL42ih45") == "OL42")
assert(qthToSquare("io91VL39FX") == "IO91")
assert(qthToSquare("JN58") == "JN58")
assert(qthToSquare("garbage!!") == "----")
}
}
@@ -1,301 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.*
import org.junit.Test
import kotlin.math.PI
import kotlin.math.sin
class CwDecoderTest {
// --- Morse table ---
@Test
fun morseToChar_basicLetters() {
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
}
@Test
fun morseToChar_numbers() {
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
}
@Test
fun morseToChar_unknown_returnsNull() {
assertNull(CwBayesianDecoder.morseToChar("......."))
assertNull(CwBayesianDecoder.morseToChar(""))
}
// --- FFT ---
@Test
fun fft_magnitudeSpectrum_detectsTone() {
val fft = CwFFT(256)
val sampleRate = 8000f
val freq = 700f
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
val mag = fft.magnitudeSpectrum(buffer)
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
var maxBin = 0
var maxVal = 0f
for (i in mag.indices) {
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
}
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
}
@Test
fun fft_magnitudeSpectrum_silence_isFlat() {
val fft = CwFFT(256)
val buffer = FloatArray(256) { 0f }
val mag = fft.magnitudeSpectrum(buffer)
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
}
@Test
fun fft_rejectsWrongSize() {
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
}
// --- Spectrogram ---
@Test
fun spectrogram_addSamples_updatesEnergy() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
// Feed multiple frames to stabilize energy normalization
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val col = spec.getCurrentColumn()
val peakBin = spec.findPeakBin()
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
}
@Test
fun spectrogram_findPeakBin_returnsValidBin() {
val spec = CwSpectrogram(sampleRate = 8000)
// Add multiple frames of 700 Hz tone
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val peakBin = spec.findPeakBin()
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
}
@Test
fun spectrogram_freqToBin_roundtrip() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
val bin = spec.freqToBin(freq)
val backFreq = spec.binToFreq(bin)
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
}
@Test
fun spectrogram_getBinEnergy_returnsCorrectLength() {
val spec = CwSpectrogram(sampleRate = 8000)
val energy = spec.getBinEnergy(0, 10)
assertEquals(10, energy.size)
}
@Test
fun spectrogram_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
spec.addSamples(FloatArray(256) { 1f })
spec.reset()
assertEquals(-1, spec.findPeakBin())
}
// --- Bayesian decoder ---
@Test
fun bayesian_processTone_dit() {
val decoder = CwBayesianDecoder()
// At 20 WPM, dot = 60 ms
val result = decoder.processTone(60f)
assertEquals('0', result.symbol)
assertTrue("Dit probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_dash() {
val decoder = CwBayesianDecoder()
// Dash = 3 * dot = 180 ms
val result = decoder.processTone(180f)
assertEquals('1', result.symbol)
assertTrue("Dash probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_unknown_returnsNull() {
val decoder = CwBayesianDecoder()
// Very long tone — low probability for both dit and dash
val result = decoder.processTone(5000f)
assertNull(result.symbol)
}
@Test
fun bayesian_processGap_interChar_returnsChar() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
// 3 dots = "000" = 'S'
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
assertEquals('S', char)
}
@Test
fun bayesian_processGap_wordGap_addsSpace() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char gap
// Now word gap
val space = decoder.processGap(420f) // 7 * dot
assertEquals(' ', space)
}
@Test
fun bayesian_decodedText_accumulates() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char
assertTrue(decoder.decodedText.isNotEmpty())
}
@Test
fun bayesian_reset() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f)
decoder.reset()
assertEquals("", decoder.decodedText)
}
@Test
fun bayesian_getSpeed() {
val decoder = CwBayesianDecoder()
// Send 3 dits at 20 WPM (60 ms each)
decoder.processTone(60f)
decoder.processTone(60f)
decoder.processTone(60f)
val speed = decoder.getSpeed()
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
}
// --- Channel tracker ---
@Test
fun channelTracker_initialState() {
val spec = CwSpectrogram(sampleRate = 8000)
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("No channels should be active initially", channels.isEmpty())
}
@Test
fun channelTracker_detectsTone() {
val spec = CwSpectrogram(sampleRate = 8000)
// Feed a tone
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
}
@Test
fun channelTracker_bestChannel() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
val best = tracker.getBestChannel()
assertNotNull("Best channel should exist", best)
if (best != null) assertTrue(best.frequency in 600f..800f)
}
@Test
fun channelTracker_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
tracker.reset()
assertNull(tracker.getBestChannel())
}
// --- Full decoder ---
@Test
fun decoder_initialState() {
val decoder = CwDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_processSilence_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 0f })
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun decoder_processNoise_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_processTone_doesNotCrash() {
val decoder = CwDecoder()
for (i in 0..20) {
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_reset() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 1f })
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_withFixedPitch() {
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
assertEquals(700f, decoder.estimatedPitch.value)
}
}
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -46,6 +47,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
@@ -143,7 +145,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
ElevatedCard(
modifier = modifier
@@ -157,13 +159,16 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -209,6 +214,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
@@ -299,7 +309,8 @@ fun SharedDialog(
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
) {
DialogShell(onDismissRequest = onDismissRequest) { padding ->
val dismissible = onCancel != null
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -333,10 +344,14 @@ fun SharedDialog(
@Composable
private fun DialogShell(
onDismissRequest: () -> Unit,
dismissible: Boolean = true,
content: @Composable (padding: Dp) -> Unit
) {
val padding = LocalSpacing.current.large
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
)
val stopSheetFling = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
@@ -351,10 +366,10 @@ private fun DialogShell(
}
}
ModalBottomSheet(
onDismissRequest = onDismissRequest,
onDismissRequest = if (dismissible) onDismissRequest else { {} },
sheetState = sheetState,
dragHandle = null,
shape = MaterialTheme.shapes.medium,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
scrimColor = Color.Black.copy(alpha = 0.64f)
) {
Column(
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04),
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0),
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212),
// onTertiary = Color(0xFF121212),
// tertiaryContainer = Color(0xFF121212),
// onTertiaryContainer = Color(0xFF121212),
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
onTertiary = Color(0xFF000000),
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background
@@ -35,12 +35,6 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
@Serializable
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming)
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="15"
android:viewportHeight="15">
<path
android:fillColor="@android:color/white"
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
</vector>
@@ -1,22 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
</vector>
@@ -1,25 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
<path
android:fillColor="@android:color/white"
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
</vector>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Ajustes</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Tipo: %s</string>
<string name="sat_type_title">Seleccionar tipo de satélite</string>
<string name="sat_type_hint">Modos: %s</string>
<string name="sat_type_title">Seleccionar modos</string>
<string name="sat_search_hint">Id - Nombre</string>
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string>
<string name="sat_group_selected">Seleccionados</string>
<string name="sat_group_available">Disponibles</string>
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
<string name="sat_group_available_count">Disponibles (%1$d)</string>
<string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string>
@@ -26,7 +30,7 @@
Esta aplicación incluye más de 9000 satélites.
No tiene sentido rastrearlos todos a la vez.
\n\nIntenta siempre limitar la lista a los que te
interesen mediante la búsqueda y el selector de tipos.</string>
interesen mediante la búsqueda y el selector de modos.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filtrar pases</string>
@@ -35,7 +39,7 @@
<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>
<string name="pass_modes_title">Tipo de modulación</string>
<string name="pass_modes_title">Seleccionar modos</string>
<string name="pass_elevation">Elevación: %.1f°</string>
<string name="pass_altitude">Altitud: %.0f km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
<string name="prefs_station_title">Ubicación de la estación</string>
<string name="prefs_station_lat_text">Latitud de estación</string>
<string name="prefs_station_lon_text">Longitud de estación</string>
<string name="prefs_locator_title">Localizador QTH</string>
<string name="prefs_locator_text">Posición por localizador</string>
<string name="prefs_data_title">Datos satelitales</string>
<string name="prefs_data_entries">Satélites: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">Vaciar</string>
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
<string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Salida de datos</string>
<string name="prefs_net_output">Red</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Salida de datos de red</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Control de radio</string>
<string name="rc_settings_title">Control CAT</string>
<string name="rc_radio_model">Modelo de radio</string>
<string name="rc_tx_device_hint">Dirección BT TX</string>
<string name="rc_rx_device_hint">Dirección BT RX</string>
<string name="rc_tx_name_hint">Nombre radio TX</string>
<string name="rc_rx_name_hint">Nombre radio RX</string>
<string name="rc_enable_switch">Activar CAT</string>
<string name="prefs_net_title">Salida de red</string>
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida Bluetooth</string>
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato</string>
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato</string>
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
<string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Actualización automática</string>
<string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<string name="nav_prefs">Настройки</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Выберите тип спутника</string>
<string name="sat_type_hint">Режимы: %s</string>
<string name="sat_type_title">Выберите режимы</string>
<string name="sat_search_hint">Id - Название</string>
<string name="sat_search_clear">Очистить</string>
<string name="sat_clear_all">Очистить</string>
<string name="sat_select_all">Выбрать</string>
<string name="sat_group_selected">Выбранные</string>
<string name="sat_group_available">Доступные</string>
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
<string name="sat_group_available_count">Доступные (%1$d)</string>
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
<string name="sat_warning_title">Внимание\!</string>
<string name="sat_warning_message">
В этом приложении перечислено более 9000 спутников.
Отслеживать их все одновременно бессмысленно.
\n\nВсегда старайтесь сузить список до тех,
которые вас интересуют, используя поиск и селектор типов.</string>
которые вас интересуют, используя поиск и выбор режимов.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фильтровать пролеты</string>
@@ -34,7 +38,7 @@
<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>
<string name="pass_modes_title">Выберите тип модуляции</string>
<string name="pass_modes_title">Выберите режимы</string>
<string name="pass_elevation">Элевация: %.1f°</string>
<string name="pass_altitude">Высота: %d км</string>
<string name="pass_empty_list_message">
@@ -95,11 +99,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
<string name="prefs_loc_success">Позиция успешно обновлена</string>
<string name="prefs_station_title">Настройки местоположения</string>
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
<string name="prefs_locator_title">Настройки локатора QTH</string>
<string name="prefs_locator_text">Введите позицию через локатор</string>
<string name="prefs_station_title">Положение станции</string>
<string name="prefs_station_lat_text">Широта станции</string>
<string name="prefs_station_lon_text">Долгота станции</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиция по локатору</string>
<string name="prefs_data_title">Данные спутников</string>
<string name="prefs_data_entries">Спутников: %s</string>
@@ -109,16 +113,30 @@
<string name="prefs_data_clear">Очистить</string>
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
<string name="prefs_data_update_success">Обновление прошло успешно</string>
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Свои источники данных</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
<string name="prefs_data_sources_title">Свои источники</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Вывод данных</string>
<string name="prefs_data_output_title">Вывод</string>
<string name="prefs_net_output">Сеть</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вывод сетевых данных</string>
<string name="nav_radiocontrol">Управление радио</string>
<string name="rc_settings_title">CAT управление</string>
<string name="rc_radio_model">Модель радио</string>
<string name="rc_tx_device_hint">BT адрес TX</string>
<string name="rc_rx_device_hint">BT адрес RX</string>
<string name="rc_tx_name_hint">Имя радио TX</string>
<string name="rc_rx_name_hint">Имя радио RX</string>
<string name="rc_enable_switch">Включить CAT</string>
<string name="prefs_net_title">Сетевой вывод</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string>
@@ -126,22 +144,22 @@
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат</string>
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
<string name="prefs_bt_title">Вывод Bluetooth</string>
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
<string name="prefs_bt_rotator_output_hint">Формат</string>
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
<string name="prefs_bt_frequency_output_hint">Формат</string>
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
<string name="prefs_other_title">Прочее</string>
<string name="prefs_other_switch_utc">Время в UTC</string>
<string name="prefs_other_switch_update">Автообновление данных</string>
<string name="prefs_other_switch_sweep">Анимация радара</string>
<string name="prefs_other_switch_sensors">Сенсоры</string>
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
<string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<string name="nav_prefs">පසුතල</string>
<!-- Satellites screen -->
<string name="sat_type_hint">වර්ගය: %s</string>
<string name="sat_type_title">චන්ද්‍රිකා ආකාරය තෝරන්න</string>
<string name="sat_type_hint">මාදිලි: %s</string>
<string name="sat_type_title">මාදිලි තෝරන්න</string>
<string name="sat_search_hint">Id - නම</string>
<string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
<string name="sat_select_all">සියල්ල තෝරන්න</string>
<string name="sat_group_selected">තෝරාගත්</string>
<string name="sat_group_available">තිබෙන</string>
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
<string name="sat_empty_list_message">
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
<string name="sat_warning_title">අවවාදයයි\!</string>
<string name="sat_warning_message">
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්‍රිකා 9000 කට වඩා තිබේ.
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
<!-- Passes screen -->
@@ -35,7 +39,7 @@
<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>
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
<string name="pass_altitude">උන්නතාශය: %d km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න</string>
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
<string name="prefs_locator_title">QTH locator පසුතල</string>
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
<string name="prefs_station_title">පොළ පිහිටීම</string>
<string name="prefs_station_lat_text">පොළ අක්‍ෂාංශය</string>
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
<string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_entries">චන්ද්‍රිකා: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">පිරිසිදු ක°</string>
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_import_satellites_error">චන්ද්‍රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">දත්ත ප්‍රතිදානය</string>
<string name="prefs_net_output">ජාලය</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
<string name="rc_settings_title">CAT පාලනය</string>
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
<string name="rc_enable_switch">CAT සබල කරන්න</string>
<string name="prefs_net_title">ජාල ප්‍රතිදානය</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_net_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string>
<string name="prefs_net_perm_error">Network අවසර පරික්‍ෂා කරන්න</string>
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
<string name="prefs_other_title">වෙනත්</string>
<string name="prefs_other_switch_utc">UTC වේලාව</string>
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">සංවේදක</string>
<string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -14,19 +14,23 @@
<string name="nav_prefs">Ayarlar</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
<string name="sat_type_title">Uydu türü seçin</string>
<string name="sat_type_hint">Modlar: %s</string>
<string name="sat_type_title">Modları seçin</string>
<string name="sat_search_hint">Id - Ad</string>
<string name="sat_search_clear">Temizle</string>
<string name="sat_clear_all">Tümünü temizle</string>
<string name="sat_select_all">Tümünü seç</string>
<string name="sat_group_selected">Seçilenler</string>
<string name="sat_group_available">Kullanılabilir</string>
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
<string name="sat_warning_title">Uyarı\!</string>
<string name="sat_warning_message">
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
Hepsini aynı anda takip etmek anlamsızdır.
\n\nListeyi yalnızca ilgilendiğiniz uydularla
daraltmak için arama ve tür seçiciyi kullanın.</string>
daraltmak için arama ve mod seçiciyi kullanın.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Geçişleri filtrele</string>
@@ -36,7 +40,7 @@
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
<string name="pass_filter_deep_space">DeepSpace (periyot &gt;225dk)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Modulasyon türü seçin</string>
<string name="pass_modes_title">Modları seçin</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -51,12 +55,11 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Roaming sayfası QTH定位器 2.0\'dan birebir taşındı: 8 haneli konum, 3x3 ızgara, kırmızı işaret, canlı GPS
\n\n* İçeriğin sistem durum/gezinme çubuklarıyla çakışması düzeltildi; ızgara sütunları referans düzenle eşleşiyor
\n\n* Roaming sayfası renkleri uygulama temasını takip eder - kırmızı gece modunda tamamen görünür
\n\n* Ayarlar: sıkışan anahtar satırları ve teşekkür aralığı düzeltildi
\n\n* Uygulama adı artık Look4Sat Pro
\n\n* Ayarlar: "İçe aktar" düğmesi "Özel URL" olarak yeniden adlandırıldı - TLE/alıcı-verici kaynak URL\'leri doğrudan düzenlenebilir
* Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
\n* Added multiple performance tweaks, improved passes calculation
\n* Fixed UTC pass time problem mentioned in issue #201
</string>
<!-- Radar screen -->
@@ -104,7 +107,7 @@
<string name="map_visible">Görünür</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Bağış yap</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
@@ -128,43 +131,44 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
<string name="prefs_station_title">İstasyon konumu ayarları</string>
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
<string name="prefs_station_title">İstasyon konumu</string>
<string name="prefs_station_lat_text">İstasyon enlemi</string>
<string name="prefs_station_lon_text">İstasyon boylamı</string>
<string name="prefs_locator_title">QTH konumlandırıcı</string>
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
<string name="prefs_data_title">Uydu verisi</string>
<string name="prefs_data_entries">Uydular: %s</string>
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
<string name="prefs_data_update">Güncelle</string>
<string name="prefs_data_import">Özel URL</string>
<string name="prefs_data_import">İçe aktar</string>
<string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_import_satellites_error">Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin.</string>
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_title">Özel kaynaklar</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
<string name="prefs_data_output_title">Veri aktarımı</string>
<string name="prefs_data_output_title">Veri çıkışı</string>
<string name="prefs_net_output">Ağ</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">Radyo Kontrolü</string>
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
<string name="rc_settings_title">CAT kontrolü</string>
<string name="rc_radio_model">Radyo Modeli</string>
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
<string name="rc_tx_device_hint">TX BT adresi</string>
<string name="rc_rx_device_hint">RX BT adresi</string>
<string name="rc_tx_name_hint">TX Radyo Adı</string>
<string name="rc_rx_name_hint">RX Radyo Adı</string>
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
<string name="prefs_net_title">Ağ veri çıkışı</string>
<string name="prefs_net_title">Ağ çıkışı</string>
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Biçim</string>
@@ -172,38 +176,36 @@
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Biçim</string>
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
<string name="prefs_bt_title">Bluetooth çıkışı</string>
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
<string name="prefs_bt_rotator_output_hint">Biçim</string>
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
<string name="prefs_bt_frequency_output_hint">Biçim</string>
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
<string name="prefs_other_title">Diğer</string>
<string name="prefs_other_switch_utc">UTC saati</string>
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
<string name="prefs_other_switch_sweep">Radar taraması</string>
<string name="prefs_other_switch_sensors">Sensör</string>
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
<string name="prefs_highlight_high">Yüksek &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA ve orijinal yazar teşekkür eder</string>
<string name="prefs_outro_title">Teşekkürler</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat users and contributors!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Налаштування</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Виберіть тип супутника</string>
<string name="sat_type_hint">Режими: %s</string>
<string name="sat_type_title">Виберіть режими</string>
<string name="sat_search_hint">Id - Назва</string>
<string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string>
<string name="sat_group_selected">Обрані</string>
<string name="sat_group_available">Доступні</string>
<string name="sat_group_selected_count">Обрані (%1$d)</string>
<string name="sat_group_available_count">Доступні (%1$d)</string>
<string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string>
@@ -26,7 +30,7 @@
У цьому додатку перелічено понад 9000 супутників.
Немає сенсу відстежувати їх усі одночасно.
\n\nЗавжди намагайтеся звузити список лише до тих,
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фільтр прольотів</string>
@@ -35,7 +39,7 @@
<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>
<string name="pass_modes_title">Виберіть тип модуляції</string>
<string name="pass_modes_title">Виберіть режими</string>
<string name="pass_elevation">Піднес.: %.1f°</string>
<string name="pass_altitude">Висота: %.0f км</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
<string name="prefs_loc_success">Позицію успішно оновлено</string>
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
<string name="prefs_locator_title">Налаштування QTH локатора</string>
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
<string name="prefs_station_title">Позиція станції</string>
<string name="prefs_station_lat_text">Широта станції</string>
<string name="prefs_station_lon_text">Довгота станції</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиція за локатором</string>
<string name="prefs_data_title">Супутникові дані</string>
<string name="prefs_data_entries">Супутників: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">Очистити</string>
<string name="prefs_data_clear_success">Дані успішно очищено</string>
<string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Вивід даних</string>
<string name="prefs_net_output">Мережа</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вивід мережевих даних</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Керування радіо</string>
<string name="rc_settings_title">CAT керування</string>
<string name="rc_radio_model">Модель радіо</string>
<string name="rc_tx_device_hint">BT адреса TX</string>
<string name="rc_rx_device_hint">BT адреса RX</string>
<string name="rc_tx_name_hint">Назва радіо TX</string>
<string name="rc_rx_name_hint">Назва радіо RX</string>
<string name="rc_enable_switch">Увімкнути CAT</string>
<string name="prefs_net_title">Мережевий вивід</string>
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
<string name="prefs_bt_title">Вивід Bluetooth</string>
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
<string name="prefs_other_title">Інше</string>
<string name="prefs_other_switch_utc">Час в UTC</string>
<string name="prefs_other_switch_update">Автооновлення даних</string>
<string name="prefs_other_switch_sweep">Анімація радара</string>
<string name="prefs_other_switch_sensors">Сенсори</string>
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
<string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -9,21 +9,23 @@
<string name="nav_sat">卫星</string>
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_mutual">匹配</string>
<string name="nav_roaming">漫游</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
<string name="sat_type_title">选择卫星类型</string>
<string name="sat_type_hint">模式:%s</string>
<string name="sat_type_title">选择模式</string>
<string name="sat_search_hint">Id - 名称</string>
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_group_selected">已选择</string>
<string name="sat_group_available">可用</string>
<string name="sat_group_selected_count">已选择(%1$d)</string>
<string name="sat_group_available_count">可用(%1$d)</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
@@ -32,22 +34,13 @@
<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>
<string name="pass_modes_title">选择调制类型</string>
<string name="pass_modes_title">选择模式</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message">
* 漫游页完整移植 QTH定位器 2.0:8 位网格、3x3 九宫格、红点查表定位、实时 GPS
\n\n* 修复内容被系统状态栏/导航栏遮挡;九宫格列宽与原版一致
\n\n* 漫游页配色跟随应用主题,红色夜间模式下清晰可见
\n\n* 设置:修复开关行挤压与感谢列表间距
\n\n* 应用名现为 Look4Sat Pro
\n\n* 设置:导入文件按钮更名为自定义URL,可直接编辑 TLE/收发机数据源地址
</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
@@ -67,17 +60,9 @@
<string name="radar_string_no">否</string>
<string name="radar_string_yes">是</string>
<string name="radar_visible">日照中</string>
<string name="radar_doppler_calc">多普勒频率计算器</string>
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
<string name="radar_cw_decoder">CW 解码器</string>
<string name="radar_cw_start">开始</string>
<string name="radar_cw_stop">停止</string>
<string name="radar_cw_reset">清空</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
@@ -110,34 +95,43 @@
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_station_title">站位</string>
<string name="prefs_station_lat_text">站位纬度</string>
<string name="prefs_station_lon_text">站位经度</string>
<string name="prefs_locator_title">QTH 定位</string>
<string name="prefs_locator_text">通过 QTH 设置站位</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
<string name="prefs_data_update">在线更新</string>
<string name="prefs_data_import">自定义URL</string>
<string name="prefs_data_import">导入文件</string>
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string>
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
<string name="prefs_data_sources_title">自定义源</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">数据输出</string>
<string name="prefs_net_output">网络</string>
<string name="prefs_bt_output">蓝牙</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">网络数据输出</string>
<string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
<string name="rc_tx_name_hint">TX 电台名称</string>
<string name="rc_rx_name_hint">RX 电台名称</string>
<string name="rc_enable_switch">启用 CAT</string>
<string name="prefs_net_title">网络输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string>
@@ -145,7 +139,7 @@
<string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_bt_title">蓝牙数据输出</string>
<string name="prefs_bt_title">蓝牙输出</string>
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_rotator_output_hint">数据格式</string>
@@ -155,39 +149,26 @@
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
<string name="prefs_net_perm_error">请检查网络权限</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 电台控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
<string name="rc_tx_name_hint">发射电台名称</string>
<string name="rc_rx_name_hint">接收电台名称</string>
<string name="rc_enable_switch">启用 CAT 控制</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_title">其他</string>
<string name="prefs_other_switch_utc">UTC 时间</string>
<string name="prefs_other_switch_update">自动更新数据</string>
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA 与原作者感谢:</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BA7OPF(过境匹配功能)
\n• BG7NTA</string>
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="app_name" translatable="false">Look4Sat</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
@@ -10,25 +10,27 @@
<string name="nav_sat">Satellites</string>
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Match</string>
<string name="nav_roaming">Roaming</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
<string name="sat_type_title">Select satellite type</string>
<string name="sat_type_hint">Modes: %s</string>
<string name="sat_type_title">Select modes</string>
<string name="sat_search_hint">Id - Name</string>
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string>
<string name="sat_group_selected">Selected</string>
<string name="sat_group_available">Available</string>
<string name="sat_group_selected_count">Selected (%1$d)</string>
<string name="sat_group_available_count">Available (%1$d)</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message">
There are over 9000 satellites listed in this app.
It makes no sense to track them all at the same time.
\n\nAlways try to narrow down the list to only the ones
you\'re interested in via search and types selector.</string>
you\'re interested in via search and the modes selector.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filter passes</string>
@@ -38,7 +40,7 @@
<string name="pass_filter_invert_time">Invert AOS window</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Select modulation type</string>
<string name="pass_modes_title">Select modes</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevation: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -53,14 +55,33 @@
\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">
* Roaming page ported verbatim from QTH定位器 2.0: 8-char locator, 3x3 grid, red marker lookup, live GPS
\n\n* Fixed content overlapping the system status/navigation bars; grid columns now match the reference layout
\n\n* Roaming page colors follow the app theme - fully visible in red night mode
\n\n* Settings: fixed squeezed toggle rows and credits spacing
\n\n* App name is now Look4Sat Pro
\n\n* Settings: "Import" button renamed to "Custom URL" - edit TLE/transceiver source URLs directly
* Added small tweaks to Sources and strings. Added official ISS source (#225)
\n* Implemented AOS window and elevation highlight filters (#226)
\n* Added cleartext traffic support for custom TLE URLs (#227)
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
\n* Added support for Icom IC-705 CAT by ruilvo (#229)
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
\n* Localized pass date and time formats for Chinese by atsunatsu (#231)
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
\n* Replaced the types selection dialog with modes selection to avoid confusion
</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
<string name="radar_notify">Notify</string>
@@ -86,16 +107,9 @@
<string name="radar_string_no">No</string>
<string name="radar_string_yes">Yes</string>
<string name="radar_visible">Visible</string>
<string name="radar_doppler_calc">Doppler Frequency Calculator</string>
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
<string name="radar_cw_decoder">CW Decoder</string>
<string name="radar_cw_start">Start</string>
<string name="radar_cw_stop">Stop</string>
<string name="radar_cw_reset">Clear</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
@@ -116,7 +130,7 @@
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donate</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
@@ -140,28 +154,27 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
<string name="prefs_loc_success">Position updated successfully</string>
<string name="prefs_station_title">Station position settings</string>
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_station_title">Station position</string>
<string name="prefs_station_lat_text">Station latitude</string>
<string name="prefs_station_lon_text">Station longitude</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">Set position via locator</string>
<string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string>
<string name="prefs_data_radios">Transceivers: %s</string>
<string name="prefs_data_update">Update</string>
<string name="prefs_data_import">Custom URL</string>
<string name="prefs_data_import">Import</string>
<string name="prefs_data_clear">Clear</string>
<string name="prefs_data_clear_success">Data was cleared successfully</string>
<string name="prefs_data_update_success">Update completed successfully</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Custom sources</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Reset to default URL</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
@@ -170,15 +183,15 @@
<!-- Radio Control -->
<string name="nav_radiocontrol">Radio Control</string>
<string name="rc_settings_title">CAT Radio Control</string>
<string name="rc_settings_title">CAT control</string>
<string name="rc_radio_model">Radio Model</string>
<string name="rc_tx_device_hint">TX Radio BT Address</string>
<string name="rc_rx_device_hint">RX Radio BT Address</string>
<string name="rc_tx_device_hint">TX BT address</string>
<string name="rc_rx_device_hint">RX BT address</string>
<string name="rc_tx_name_hint">TX Radio Name</string>
<string name="rc_rx_name_hint">RX Radio Name</string>
<string name="rc_enable_switch">Enable CAT Control</string>
<string name="rc_enable_switch">Enable CAT</string>
<string name="prefs_net_title">Network data output</string>
<string name="prefs_net_title">Network output</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
@@ -186,29 +199,31 @@
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Bluetooth data output</string>
<string name="prefs_bt_title">Bluetooth output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Device id</string>
<string name="prefs_bt_rotator_output_hint">Data format</string>
<string name="prefs_bt_rotator_device_hint">Device ID</string>
<string name="prefs_bt_rotator_output_hint">Format</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Device id</string>
<string name="prefs_bt_frequency_output_hint">Data format</string>
<string name="prefs_bt_frequency_device_hint">Device ID</string>
<string name="prefs_bt_frequency_output_hint">Format</string>
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</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_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_title">Other</string>
<string name="prefs_other_switch_utc">UTC time</string>
<string name="prefs_other_switch_update">Auto-update data</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Use sensors</string>
<string name="prefs_other_switch_night_mode">Night filter</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
<string name="prefs_highlight_high">High &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; the original author would like to thank</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_thanks" translatable="false">
* Look4Sat users and contributors!
\n* David A. B. Johnson (predict4java)
@@ -217,8 +232,6 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BA7OPF (pass matching feature)
\n* BG7NTA
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
@@ -1,2 +1,8 @@
* Added required tweaks to support Android 17 (API 37)
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
* Added ARISS source, tweaked SSTV sensitivity #225
* Implemented AOS window and elev filter #226
* Added cleartext traffic support for TLEs #227
* Fixed OMM import, tweaked error messages #228
* Added support for Icom CAT by ruilvo #229
* Added tweakable Radar offset by qwqtoday #230
* Localized date/time formats by atsunatsu #231
* Added linear radio Doppler calc by atsunatsu #232
-7
View File
@@ -1,7 +0,0 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.mutual"
}
@@ -1,262 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
/**
* Draggable dual-station elevation curve chart.
* Controlled component: [progress] (0..1) and [onProgressChange] are owned by the parent,
* so the same time cursor can be shared with the radar track view.
*/
@Composable
fun ElevationCurveChart(
samples: List<Pair<Long, Pair<Double, Double>>>,
startTime: Long,
endTime: Long,
maxElev: Double,
progress: Float = 0.5f,
onProgressChange: (Float) -> Unit = {},
modifier: Modifier = Modifier
) {
if (samples.isEmpty()) return
val colorA = MaterialTheme.colorScheme.primary
val colorB = MaterialTheme.colorScheme.tertiary
val gridColor = MaterialTheme.colorScheme.outlineVariant
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
val colorAArgb = colorA.toArgb()
val colorBArgb = colorB.toArgb()
val textColorArgb = textColor.toArgb()
val onSurfaceArgb = onSurfaceColor.toArgb()
val gridColorArgb = gridColor.toArgb()
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Column(modifier = modifier.fillMaxWidth()) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.pointerInput(Unit) {
detectTapGestures { offset ->
onProgressChange((offset.x / size.width.toFloat()).coerceIn(0f, 1f))
}
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
onProgressChange((change.position.x / size.width.toFloat()).coerceIn(0f, 1f))
}
}
) {
val chartWidth = size.width
val chartHeight = size.height
val padding = 40f
val plotLeft = padding
val plotRight = chartWidth - 10f
val plotTop = 10f
val plotBottom = chartHeight - padding
val plotWidth = plotRight - plotLeft
val plotHeight = plotBottom - plotTop
if (plotWidth <= 0f || plotHeight <= 0f) return@Canvas
val minTime = startTime
val maxTime = endTime
val timeRange = (maxTime - minTime).toFloat()
if (timeRange <= 0f) return@Canvas
// Grid lines
val gridSteps = 4
for (i in 0..gridSteps) {
val y = plotBottom - (plotHeight * i / gridSteps)
drawLine(gridColor, Offset(plotLeft, y), Offset(plotRight, y), strokeWidth = 1f)
val elev = (maxElev * i / gridSteps).roundToInt()
drawContext.canvas.nativeCanvas.drawText(
"$elev°", 2f, y + 4f,
android.graphics.Paint().apply {
color = textColorArgb
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
}
// Time axis
val timeSteps = 4
for (i in 0..timeSteps) {
val x = plotLeft + (plotWidth * i / timeSteps)
val t = startTime + ((endTime - startTime) * i / timeSteps)
drawContext.canvas.nativeCanvas.drawText(
timeFormat.format(Date(t)), x - 20f, chartHeight - 2f,
android.graphics.Paint().apply {
color = textColorArgb
textSize = 22f
textAlign = android.graphics.Paint.Align.LEFT
}
)
}
// Elevation curves (smooth cubic Bezier)
val pathA = Path()
val pathB = Path()
val ptsA = mutableListOf<Offset>()
val ptsB = mutableListOf<Offset>()
for ((time, elev) in samples) {
val x = plotLeft + ((time - minTime).toFloat() / timeRange) * plotWidth
val yA = plotBottom - ((elev.first.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
val yB = plotBottom - ((elev.second.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
ptsA.add(Offset(x, yA))
ptsB.add(Offset(x, yB))
}
if (ptsA.size >= 2) {
pathA.moveTo(ptsA[0].x, ptsA[0].y)
pathB.moveTo(ptsB[0].x, ptsB[0].y)
for (i in 1 until ptsA.size) {
val p0a = ptsA.getOrNull(i - 2) ?: ptsA[i - 1]
val p1a = ptsA[i - 1]
val p2a = ptsA[i]
val p3a = ptsA.getOrNull(i + 1) ?: ptsA[i]
val cp1a = Offset(p1a.x + (p2a.x - p0a.x) / 6f, p1a.y + (p2a.y - p0a.y) / 6f)
val cp2a = Offset(p2a.x - (p3a.x - p1a.x) / 6f, p2a.y - (p3a.y - p1a.y) / 6f)
pathA.cubicTo(cp1a.x, cp1a.y, cp2a.x, cp2a.y, p2a.x, p2a.y)
val p0b = ptsB.getOrNull(i - 2) ?: ptsB[i - 1]
val p1b = ptsB[i - 1]
val p2b = ptsB[i]
val p3b = ptsB.getOrNull(i + 1) ?: ptsB[i]
val cp1b = Offset(p1b.x + (p2b.x - p0b.x) / 6f, p1b.y + (p2b.y - p0b.y) / 6f)
val cp2b = Offset(p2b.x - (p3b.x - p1b.x) / 6f, p2b.y - (p3b.y - p1b.y) / 6f)
pathB.cubicTo(cp1b.x, cp1b.y, cp2b.x, cp2b.y, p2b.x, p2b.y)
}
}
drawPath(pathA, colorA, style = Stroke(width = 2.5f))
drawPath(pathB, colorB, style = Stroke(width = 2.5f))
// Drag indicator
val dragX = plotLeft + progress * plotWidth
val dragTime = startTime + (progress * (endTime - startTime)).toLong()
drawLine(
onSurfaceColor,
Offset(dragX, plotTop), Offset(dragX, plotBottom),
strokeWidth = 2f
)
val dragElev = getElevationAtTime(samples, dragTime)
val dragElevA = (dragElev?.first?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
val dragElevB = (dragElev?.second?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
drawContext.canvas.nativeCanvas.drawText(
timeFormat.format(Date(dragTime)),
dragX - 24f, plotBottom + 16f,
android.graphics.Paint().apply {
color = onSurfaceArgb
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
drawContext.canvas.nativeCanvas.drawText(
"A:${dragElevA}°",
dragX + 6f, plotTop + 16f,
android.graphics.Paint().apply {
color = colorAArgb
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
drawContext.canvas.nativeCanvas.drawText(
"B:${dragElevB}°",
dragX + 6f, plotTop + 42f,
android.graphics.Paint().apply {
color = colorBArgb
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
}
// Legend
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text("● 站点A", color = colorA, fontSize = 12.sp)
Text("● 站点B", color = colorB, fontSize = 12.sp)
}
}
}
private fun getElevationAtTime(
samples: List<Pair<Long, Pair<Double, Double>>>,
time: Long
): Pair<Double, Double>? {
if (samples.isEmpty()) return null
if (samples.size == 1) return samples[0].second
if (time <= samples[0].first) return samples[0].second
if (time >= samples.last().first) return samples.last().second
var lo = 0
var hi = samples.size - 1
while (hi - lo > 1) {
val mid = (lo + hi) / 2
if (samples[mid].first <= time) lo = mid
else hi = mid
}
val t0 = samples[lo].first
val t1 = samples[hi].first
val frac = if (t1 > t0) (time - t0).toFloat() / (t1 - t0).toFloat() else 0f
val e0 = samples[lo].second
val e1 = samples[hi].second
return Pair(
e0.first + (e1.first - e0.first) * frac,
e0.second + (e1.second - e0.second) * frac
)
}
@@ -1,53 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
/**
* A mutual pass where a satellite is visible from two stations simultaneously.
*
* @param catNum Satellite catalog number
* @param name Satellite name
* @param startTime Common AOS time (millis)
* @param endTime Common LOS time (millis)
* @param maxElevationA Peak elevation for station A
* @param maxElevationB Peak elevation for station B
* @param elevationSamples List of (time, (elevationA, elevationB)) pairs
* @param trackSamples List of radar-track samples (azimuth/elevation for both stations, degrees)
*/
data class MutualPass(
val catNum: Int,
val name: String,
val startTime: Long,
val endTime: Long,
val maxElevationA: Double,
val maxElevationB: Double,
val elevationSamples: List<Pair<Long, Pair<Double, Double>>>,
val trackSamples: List<TrackSample> = emptyList()
)
/**
* One radar-track sample: satellite position as seen from both stations.
* All angles are in degrees.
*/
data class TrackSample(
val time: Long,
val azimuthA: Double,
val elevationA: Double,
val azimuthB: Double,
val elevationB: Double
)
@@ -1,223 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
private const val CIRCLES = 3 // 30°, 60°, 90° rings
private const val STROKE_WIDTH = 2.5f
/**
* Polar radar chart showing the satellite track as seen from both stations
* on one plot. Station A is drawn solid, station B dashed.
* Controlled: [progress] (0..1) and [onProgressChange] are owned by the parent
* so the time cursor can be shared with the elevation curve chart.
*/
@Composable
fun MutualRadarView(
trackSamples: List<TrackSample>,
progress: Float = 0.5f,
labelA: String = "站点A",
labelB: String = "站点B",
modifier: Modifier = Modifier
) {
if (trackSamples.isEmpty()) return
val colorA = MaterialTheme.colorScheme.primary
val colorB = MaterialTheme.colorScheme.tertiary
val gridColor = MaterialTheme.colorScheme.outlineVariant
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val measurer = rememberTextMeasurer()
// Current-time samples (both stations' position at the shared cursor)
val t0 = trackSamples.first().time
val t1 = trackSamples.last().time
val selectedTime = t0 + ((t1 - t0) * progress).toLong()
val visibleSamples = trackSamples.filter { it.time <= selectedTime }
val currentSample = visibleSamples.lastOrNull() ?: trackSamples.first()
Column(modifier = modifier.fillMaxWidth()) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.padding(4.dp)
) {
val radius = size.minDimension / 2f * 0.92f
val center = Offset(size.width / 2f, size.height / 2f)
// Grid: elevation rings + azimuth spokes + cardinal labels
drawRadarGrid(center, radius, gridColor, textColor, measurer)
// Track paths (only the above-horizon arc, split at the 0/360° azimuth wrap)
val pathA = Path()
val pathB = Path()
var firstVisibleA = true
var firstVisibleB = true
var lastAzimA: Double? = null
var lastAzimB: Double? = null
trackSamples.forEach { sample ->
if (sample.elevationA > 0f) {
val p = sph2Cart(center, sample.azimuthA, sample.elevationA, radius)
val wrapA = lastAzimA != null && kotlin.math.abs(azimuthDelta(sample.azimuthA - lastAzimA!!)) > 180.0
if (firstVisibleA || wrapA) {
pathA.moveTo(p.x, p.y)
firstVisibleA = false
} else {
pathA.lineTo(p.x, p.y)
}
lastAzimA = sample.azimuthA
}
if (sample.elevationB > 0f) {
val p = sph2Cart(center, sample.azimuthB, sample.elevationB, radius)
val wrapB = lastAzimB != null && kotlin.math.abs(azimuthDelta(sample.azimuthB - lastAzimB!!)) > 180.0
if (firstVisibleB || wrapB) {
pathB.moveTo(p.x, p.y)
firstVisibleB = false
} else {
pathB.lineTo(p.x, p.y)
}
lastAzimB = sample.azimuthB
}
}
// Solid line for station A, dashed for station B
drawPath(pathA, colorA, style = Stroke(STROKE_WIDTH))
drawPath(
pathB, colorB,
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)))
)
// AOS / LOS markers on both tracks
val first = trackSamples.first()
val last = trackSamples.last()
val aosA = sph2Cart(center, first.azimuthA, first.elevationA, radius)
val aosB = sph2Cart(center, first.azimuthB, first.elevationB, radius)
val losA = sph2Cart(center, last.azimuthA, last.elevationA, radius)
val losB = sph2Cart(center, last.azimuthB, last.elevationB, radius)
// AOS: hollow circle, LOS: filled circle
if (first.elevationA > 0f) drawCircle(colorA, 7f, aosA, style = Stroke(2.5f))
if (first.elevationB > 0f) drawCircle(colorB, 7f, aosB, style = Stroke(2.5f))
if (last.elevationA > 0f) drawCircle(colorA, 5f, losA)
if (last.elevationB > 0f) drawCircle(colorB, 5f, losB)
// Shared time-cursor positions (both stations at selectedTime)
val cursorA = sph2Cart(center, currentSample.azimuthA, currentSample.elevationA, radius)
val cursorB = sph2Cart(center, currentSample.azimuthB, currentSample.elevationB, radius)
drawCircle(colorA, 14f, cursorA, style = Stroke(3f))
drawCircle(colorA, 6f, cursorA)
drawCircle(colorB, 14f, cursorB, style = Stroke(3f))
drawCircle(colorB, 6f, cursorB)
// Center dot
drawCircle(gridColor, 4f, center)
}
// Legend
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text("● $labelA", color = colorA, fontSize = 12.sp)
Text("- - $labelB", color = colorB, fontSize = 12.sp)
}
}
}
private fun DrawScope.drawRadarGrid(
center: Offset,
radius: Float,
color: Color,
textColor: Color,
measurer: TextMeasurer
) {
// Elevation rings: 30°, 60°, 90°(center) — outer edge is horizon (0°)
val step = radius / CIRCLES
for (i in 0 until CIRCLES) {
drawCircle(color, radius - step * i, center, style = Stroke(1.5f))
}
// Cardinal spokes
drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), 1.5f)
drawLine(color, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), 1.5f)
// Diagonal spokes (lighter)
val diag = radius * 0.7071f
drawLine(
color.copy(alpha = 0.5f),
Offset(center.x - diag, center.y - diag), Offset(center.x + diag, center.y + diag), 1f
)
drawLine(
color.copy(alpha = 0.5f),
Offset(center.x - diag, center.y + diag), Offset(center.x + diag, center.y - diag), 1f
)
// Elevation ring labels: 30° on the outer ring, 60° on the middle ring, 90° at center
// (outer edge is the 0° horizon). Labels sit just above their ring.
val style = TextStyle(color = textColor, fontSize = 11.sp)
drawText(measurer, "30°", Offset(center.x + 6f, (center.y - (radius - step)) - 24f), style = style)
drawText(measurer, "60°", Offset(center.x + 6f, (center.y - (radius - 2 * step)) - 24f), style = style)
drawText(measurer, "90°", Offset(center.x + 6f, center.y - 18f), style = style)
// Cardinal labels
drawText(measurer, "N", Offset(center.x - 8f, center.y - radius - 20f), style = style)
drawText(measurer, "E", Offset(center.x + radius + 4f, center.y - 10f), style = style)
drawText(measurer, "S", Offset(center.x - 6f, center.y + radius + 2f), style = style)
drawText(measurer, "W", Offset(center.x - radius - 24f, center.y - 10f), style = style)
}
/** Normalize an azimuth delta (degrees) into the [-180, 180] range. */
private fun azimuthDelta(deltaDeg: Double): Double {
var d = deltaDeg % 360.0
if (d > 180.0) d -= 360.0
if (d < -180.0) d += 360.0
return d
}
/** Convert azimuth (deg, 0=N, clockwise) and elevation (deg) to canvas offset. */
private fun sph2Cart(center: Offset, azimDeg: Double, elevDeg: Double, r: Float): Offset {
val azimRad = azimDeg * PI / 180.0
val elevRad = elevDeg * PI / 180.0
val radius = r * (PI / 2 - elevRad) / (PI / 2)
return Offset(
x = center.x + (radius * cos(PI / 2 - azimRad)).toFloat(),
y = center.y - (radius * sin(PI / 2 - azimRad)).toFloat()
)
}
@@ -1,590 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun MutualScreen(
viewModel: MutualViewModel,
navigateUp: () -> Unit = {},
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
modifier: Modifier = Modifier
) {
val state by viewModel.uiState.collectAsState()
ScreenColumn(
topBar = { isVertical ->
TopBar(
isVerticalLayout = isVertical,
startAction = {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
},
topInfo = {
Text(
text = "过境匹配",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
},
bottomInfo = {
if (state.mutualPasses.isNotEmpty()) {
Text(
text = "找到 ${state.mutualPasses.size} 个匹配",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
endAction = {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(24.dp)
.width(24.dp),
strokeWidth = 2.dp
)
}
}
)
}
) { isVertical ->
MutualContent(
state = state,
isVertical = isVertical,
onQuery = viewModel::queryMutualPasses,
onSelectPass = viewModel::onSelectPass,
onNavigateToRadar = navigateToRadar,
onStationALat = viewModel::onStationALat,
onStationALon = viewModel::onStationALon,
onStationAGrid = viewModel::onStationAGrid,
onStationAMinElev = viewModel::onStationAMinElev,
onStationBLat = viewModel::onStationBLat,
onStationBLon = viewModel::onStationBLon,
onStationBGrid = viewModel::onStationBGrid,
onStationBMinElev = viewModel::onStationBMinElev,
onUseCurrentPosition = viewModel::onUseCurrentPosition,
onHoursAhead = viewModel::onHoursAhead,
onClearError = viewModel::clearError
)
}
}
@Composable
private fun MutualContent(
state: MutualUiState,
isVertical: Boolean,
onQuery: () -> Unit,
onSelectPass: (Int) -> Unit,
onNavigateToRadar: (Int, Long, MutualPassData?) -> Unit,
onStationALat: (String) -> Unit,
onStationALon: (String) -> Unit,
onStationAGrid: (String) -> Unit,
onStationAMinElev: (Double) -> Unit,
onStationBLat: (String) -> Unit,
onStationBLon: (String) -> Unit,
onStationBGrid: (String) -> Unit,
onStationBMinElev: (Double) -> Unit,
onUseCurrentPosition: () -> Unit,
onHoursAhead: (Int) -> Unit,
onClearError: () -> Unit
) {
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Error message
val errorMsg = state.errorMessage
if (errorMsg != null) {
item {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMsg,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
// Input form
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
MatchIntroCard()
if (isVertical) {
StationInputCard(
title = "你的位置",
titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat,
lon = state.stationALon,
grid = state.stationAGrid,
minElev = state.stationAMinElev,
latPlaceholder = "39.9042",
lonPlaceholder = "116.4074",
gridPlaceholder = "ON79uj",
onLatChange = onStationALat,
onLonChange = onStationALon,
onGridChange = onStationAGrid,
onMinElevChange = onStationAMinElev,
currentPositionAction = onUseCurrentPosition
)
StationInputCard(
title = "友台位置",
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
grid = state.stationBGrid,
minElev = state.stationBMinElev,
latPlaceholder = "34.0522",
lonPlaceholder = "-118.2437",
gridPlaceholder = "PM01tv",
onLatChange = onStationBLat,
onLonChange = onStationBLon,
onGridChange = onStationBGrid,
onMinElevChange = onStationBMinElev
)
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
StationInputCard(
title = "你的位置",
titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat,
lon = state.stationALon,
grid = state.stationAGrid,
minElev = state.stationAMinElev,
latPlaceholder = "39.9042",
lonPlaceholder = "116.4074",
gridPlaceholder = "ON79uj",
onLatChange = onStationALat,
onLonChange = onStationALon,
onGridChange = onStationAGrid,
onMinElevChange = onStationAMinElev,
currentPositionAction = onUseCurrentPosition,
modifier = Modifier.weight(1f)
)
StationInputCard(
title = "友台位置",
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
grid = state.stationBGrid,
minElev = state.stationBMinElev,
latPlaceholder = "34.0522",
lonPlaceholder = "-118.2437",
gridPlaceholder = "PM01tv",
onLatChange = onStationBLat,
onLonChange = onStationBLon,
onGridChange = onStationBGrid,
onMinElevChange = onStationBMinElev,
modifier = Modifier.weight(1f)
)
}
}
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
}
// Results
itemsIndexed(state.mutualPasses) { index, pass ->
val mutualData = MutualPassData(
samples = pass.elevationSamples,
trackSamples = pass.trackSamples.map {
TrackSampleData(
time = it.time,
azimuthA = it.azimuthA,
elevationA = it.elevationA,
azimuthB = it.azimuthB,
elevationB = it.elevationB
)
},
startTime = pass.startTime,
endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
labelA = "你",
labelB = "友台"
)
MutualPassCard(
pass = pass,
isExpanded = state.selectedPassIndex == index,
timeFormat = timeFormat,
minElevA = state.stationAMinElev,
minElevB = state.stationBMinElev,
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
onNavigateToRadar = { onNavigateToRadar(pass.catNum, pass.startTime, mutualData) }
)
}
}
}
@Composable
private fun MatchIntroCard(modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "位置匹配",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "输入双方位置,查找共同可见的卫星过境窗口",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun StationInputCard(
title: String,
titleColor: androidx.compose.ui.graphics.Color,
lat: String,
lon: String,
grid: String,
minElev: Double,
latPlaceholder: String,
lonPlaceholder: String,
gridPlaceholder: String,
onLatChange: (String) -> Unit,
onLonChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onMinElevChange: (Double) -> Unit,
modifier: Modifier = Modifier,
currentPositionAction: (() -> Unit)? = null
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = titleColor
)
if (currentPositionAction != null) {
OutlinedButton(onClick = currentPositionAction) {
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lat,
onValueChange = onLatChange,
label = { Text("纬度") },
placeholder = { Text(latPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = lon,
onValueChange = onLonChange,
label = { Text("经度") },
placeholder = { Text(lonPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = grid,
onValueChange = onGridChange,
label = { Text("网格") },
placeholder = { Text(gridPlaceholder) },
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${minElev.toInt()}°",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = minElev.toFloat(),
onValueChange = { onMinElevChange(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
}
}
}
@Composable
private fun MatchSearchCard(
hoursAhead: Int,
isCalculating: Boolean,
onHoursAhead: (Int) -> Unit,
onQuery: () -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "时间范围",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(6, 12, 24, 48, 72).forEach { hours ->
FilterChip(
selected = hoursAhead == hours,
onClick = { onHoursAhead(hours) },
label = { Text("${hours}h") }
)
}
}
Button(
onClick = onQuery,
enabled = !isCalculating,
modifier = Modifier.fillMaxWidth()
) {
if (isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(18.dp)
.width(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCalculating) "计算中..." else "查询匹配")
}
}
}
}
@Composable
private fun MutualPassCard(
pass: MutualPass,
isExpanded: Boolean,
timeFormat: SimpleDateFormat,
minElevA: Double,
minElevB: Double,
onClick: () -> Unit,
onNavigateToRadar: () -> Unit
) {
// Shared time cursor: both the elevation curve and the radar track view
// are controlled by this single progress value for bidirectional drag sync.
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
// Filter to only show the portion where both stations are above their minElev
val visibleSamples = remember(pass, minElevA, minElevB) {
pass.elevationSamples.filter { (_, elev) ->
elev.first >= minElevA && elev.second >= minElevB
}
}
val visibleTracks = remember(pass, minElevA, minElevB) {
pass.trackSamples.filter { it.elevationA >= minElevA && it.elevationB >= minElevB }
}
val visibleStart = visibleSamples.firstOrNull()?.first ?: pass.startTime
val visibleEnd = visibleSamples.lastOrNull()?.first ?: pass.endTime
val adjustedMaxElev = maxOf(
visibleSamples.maxOfOrNull { (_, elev) -> elev.first } ?: 10.0,
visibleSamples.maxOfOrNull { (_, elev) -> elev.second } ?: 10.0,
10.0
)
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
Text(
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "你: ${pass.maxElevationA}°",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "友台: ${pass.maxElevationB}°",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Dual-station radar track (polar plot)
if (visibleTracks.isNotEmpty()) {
Text(
text = "双方轨迹",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
MutualRadarView(
trackSamples = visibleTracks,
progress = dragProgress,
labelA = "你",
labelB = "友台"
)
Spacer(Modifier.height(8.dp))
}
if (visibleSamples.isNotEmpty()) {
ElevationCurveChart(
samples = visibleSamples,
startTime = visibleStart,
endTime = visibleEnd,
maxElev = adjustedMaxElev,
progress = dragProgress,
onProgressChange = { dragProgress = it }
)
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onNavigateToRadar,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.ic_radar),
contentDescription = null,
modifier = Modifier.height(16.dp).width(16.dp)
)
Spacer(Modifier.width(6.dp))
Text("查看雷达")
}
}
}
}
}
}
@@ -1,560 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.roundToInt
data class MutualUiState(
val stationALat: String = "",
val stationALon: String = "",
val stationAGrid: String = "",
val stationAMinElev: Double = 10.0,
val stationBLat: String = "",
val stationBLon: String = "",
val stationBGrid: String = "",
val stationBMinElev: Double = 10.0,
val hoursAhead: Int = 24,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val selectedPassIndex: Int = -1,
val errorMessage: String? = null
)
class MutualViewModel(
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(MutualUiState())
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
init {
// Pre-fill station A with the user's current station position (as grid),
// and default min elevation to the same value used by the main radar passes
val pos = settingsRepo.stationPosition.value
val grid = latLonToGrid(pos.latitude, pos.longitude)
_uiState.update { it.copy(
stationAGrid = grid,
stationALat = "%.4f".format(pos.latitude),
stationALon = "%.4f".format(pos.longitude),
stationAMinElev = 0.0,
stationBMinElev = 0.0
) }
}
fun onStationALat(value: String) {
_uiState.update { it.copy(stationALat = value) }
val lat = value.toDoubleOrNull()
val lon = _uiState.value.stationALon.toDoubleOrNull()
if (lat != null && lon != null) {
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
}
}
fun onStationALon(value: String) {
_uiState.update { it.copy(stationALon = value) }
val lat = _uiState.value.stationALat.toDoubleOrNull()
val lon = value.toDoubleOrNull()
if (lat != null && lon != null) {
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
}
}
fun onStationAGrid(value: String) {
val old = _uiState.value.stationAGrid
_uiState.update { it.copy(stationAGrid = value) }
if (value.trim().uppercase() == old.trim().uppercase()) return
val pos = gridToLatLon(value.trim().uppercase())
if (pos != null) {
_uiState.update { it.copy(
stationALat = "%.4f".format(pos.latitude),
stationALon = "%.4f".format(pos.longitude)
)}
}
}
fun onStationBLat(value: String) {
_uiState.update { it.copy(stationBLat = value) }
val lat = value.toDoubleOrNull()
val lon = _uiState.value.stationBLon.toDoubleOrNull()
if (lat != null && lon != null) {
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
}
}
fun onStationBLon(value: String) {
_uiState.update { it.copy(stationBLon = value) }
val lat = _uiState.value.stationBLat.toDoubleOrNull()
val lon = value.toDoubleOrNull()
if (lat != null && lon != null) {
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
}
}
fun onStationBGrid(value: String) {
val old = _uiState.value.stationBGrid
_uiState.update { it.copy(stationBGrid = value) }
if (value.trim().uppercase() == old.trim().uppercase()) return
val pos = gridToLatLon(value.trim().uppercase())
if (pos != null) {
_uiState.update { it.copy(
stationBLat = "%.4f".format(pos.latitude),
stationBLon = "%.4f".format(pos.longitude)
)}
}
}
fun onStationAMinElev(value: Double) = _uiState.update { it.copy(stationAMinElev = value) }
fun onStationBMinElev(value: Double) = _uiState.update { it.copy(stationBMinElev = value) }
fun onUseCurrentPosition() {
val pos = settingsRepo.stationPosition.value
_uiState.update { it.copy(
stationALat = "%.4f".format(pos.latitude),
stationALon = "%.4f".format(pos.longitude),
stationAGrid = latLonToGrid(pos.latitude, pos.longitude)
) }
}
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
fun queryMutualPasses() {
val state = _uiState.value
// Resolve positions from lat/lon or grid
var posA = resolvePosition(state.stationALat, state.stationALon, state.stationAGrid)
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
if (posA == null || posB == null) {
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标或网格(4/6/8位)") }
return
}
val satellites = satelliteRepo.satellites.value
if (satellites.isEmpty()) {
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") }
return
}
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) }
viewModelScope.launch {
val time = System.currentTimeMillis()
val minElevA = state.stationAMinElev
val minElevB = state.stationBMinElev
val hours = state.hoursAhead
val results = withContext(Dispatchers.Default) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
}
val errorMsg = if (results.isEmpty()) {
// Debug: check if the main pass list is the culprit
val passCount = satelliteRepo.passes.value.size
val satCount = satellites.size
when {
passCount == 0 -> "过境列表为空,请先在卫星列表中选择卫星"
satCount == 0 -> "没有已选中的卫星"
else -> "未找到共同过境(${passCount}个过境,${satCount}颗卫星)"
}
} else null
_uiState.update {
it.copy(
mutualPasses = results.sortedBy { mp -> mp.startTime },
isCalculating = false,
errorMessage = errorMsg
)
}
}
}
/** Resolve a position from lat/lon text or grid square text. */
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
val trimmed = gridText.trim().uppercase()
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
return gridToLatLon(trimmed)
}
val lat = latText.toDoubleOrNull()
val lon = lonText.toDoubleOrNull()
return if (lat != null && lon != null) GeoPos(lat, lon) else null
}
/** Convert lat/lon to Maidenhead grid (6-char). */
private fun latLonToGrid(lat: Double, lon: Double): String {
var adjLon = (lon + 180.0) % 360.0
var adjLat = (lat + 90.0) % 180.0
val fieldLon = (adjLon / 20.0).toInt()
val fieldLat = (adjLat / 10.0).toInt()
adjLon -= fieldLon * 20.0
adjLat -= fieldLat * 10.0
val squareLon = (adjLon / 2.0).toInt()
val squareLat = (adjLat / 1.0).toInt()
adjLon -= squareLon * 2.0
adjLat -= squareLat * 1.0
val subLon = (adjLon * 60.0 / 5.0).toInt()
val subLat = (adjLat * 60.0 / 2.5).toInt()
return buildString {
append('A' + fieldLon)
append('A' + fieldLat)
append('0' + squareLon)
append('0' + squareLat)
append('A' + subLon)
append('A' + subLat)
}
}
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
private fun gridToLatLon(grid: String): GeoPos? {
val g = grid.uppercase()
if (g.length < 4) return null
val lonField = (g[0] - 'A').toDouble() * 20.0
val latField = (g[1] - 'A').toDouble() * 10.0
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
val lonSquare = (g[2] - '0').toDouble() * 2.0
val latSquare = (g[3] - '0').toDouble() * 1.0
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
var lon = lonField + lonSquare
var lat = latField + latSquare
if (g.length >= 6) {
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
lon += lonSub
lat += latSub
if (g.length >= 8) {
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
lon += lonExt + 15.0 / 3600.0
lat += latExt + 7.5 / 3600.0
} else {
lon += 2.5 / 60.0
lat += 1.25 / 60.0
}
} else {
lon += 1.0
lat += 0.5
}
lon = lon - 180.0
lat = lat - 90.0
return GeoPos(lat, lon)
}
private fun findMutualPasses(
satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
time: Long, hours: Int
): List<MutualPass> {
val endTime = time + hours * 60L * 60L * 1000L
val sampleInterval = 5_000L
// Use the main page's pass list for AOS/LOS times, then sample the curves
// using the actual positions. The passes list is already computed by getLeoPass
// and its AOS/LOS times match the Passes page exactly.
val existingPasses = satelliteRepo.passes.value
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
if (results.isNotEmpty()) return results
// Fallback: try the independent search.
return findMutualPassesFallback(satellites, posA, posB,
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
}
/** Reuse the main page's pass list. Uses AOS/LOS times directly (no refineEdge) */
private fun findMutualPassesFromList(
existingPasses: List<OrbitalPass>,
satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
time: Long, endTime: Long, sampleInterval: Long
): List<MutualPass> {
val results = mutableListOf<MutualPass>()
for (pass in existingPasses) {
if (pass.losTime <= time || pass.aosTime >= endTime) continue
if (pass.isDeepSpace) continue
if (pass.orbitalObject.data.meanmo < 1e-8) continue
val sat = pass.orbitalObject
// Refine the AOS/LOS to the actual posA/posB (0° horizon).
// This ensures the elevation curve starts from ~0°.
val refinedAos = refineEdge(sat, posA, posB, pass.aosTime, 1_000L, goingUp = true)
val refinedLos = refineEdge(sat, posA, posB, pass.losTime, 1_000L, goingUp = false)
if (refinedLos <= refinedAos) continue
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
val tracks = mutableListOf<TrackSample>()
var maxElevA = 0.0
var maxElevB = 0.0
var tSample = refinedAos
while (tSample <= refinedLos) {
// Use getElevation (same function used by getLeoPass) for elevation,
// and getFullPosition only for azimuth.
val elevA = sat.getElevation(posA, tSample) * 180.0 / PI
val elevB = sat.getElevation(posB, tSample) * 180.0 / PI
val fullA = sat.getFullPosition(posA, tSample)
val fullB = sat.getFullPosition(posB, tSample)
if (elevA > maxElevA) maxElevA = elevA
if (elevB > maxElevB) maxElevB = elevB
samples.add(tSample to (elevA to elevB))
tracks.add(
TrackSample(
time = tSample,
azimuthA = fullA.azimuth * 180.0 / PI,
elevationA = elevA,
azimuthB = fullB.azimuth * 180.0 / PI,
elevationB = elevB
)
)
tSample += sampleInterval
}
// Search uses the 0° horizon as the pass boundary, but the user sliders are
// a final visibility filter. Both stations must exceed their own threshold;
// otherwise the result would expand to an empty filtered chart.
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
results.add(
MutualPass(
catNum = pass.catNum,
name = pass.orbitalObject.data.name,
startTime = refinedAos,
endTime = refinedLos,
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
elevationSamples = samples,
trackSamples = tracks
)
)
}
}
return results
}
/** Fallback: search passes independently (same logic as original findMutualPasses). */
private fun findMutualPassesFallback(
satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
time: Long, endTime: Long, sampleInterval: Long
): List<MutualPass> {
val results = mutableListOf<MutualPass>()
for (sat in satellites) {
if (sat.data.meanmo < 1e-8) continue
var searchStart = time
while (true) {
val t = findNextMutualPass(sat, posA, posB, searchStart, endTime)
if (t == null) break
val (aos, los) = t
val refinedAos = refineEdge(sat, posA, posB, aos, 1_000L, true)
val refinedLos = refineEdge(sat, posA, posB, los, 1_000L, false)
if (refinedLos <= refinedAos) continue
val mutualPass = sampleMutualPass(sat, posA, posB, refinedAos, refinedLos,
minElevADeg, minElevBDeg, sampleInterval)
if (mutualPass != null) results.add(mutualPass)
searchStart = refinedLos + 120_000L
}
}
return results
}
/** Sample elevation/azimuth data for one mutual pass window. */
private fun sampleMutualPass(
sat: OrbitalObject, posA: GeoPos, posB: GeoPos,
refinedAos: Long, refinedLos: Long,
minElevADeg: Double, minElevBDeg: Double,
sampleInterval: Long
): MutualPass? {
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
val tracks = mutableListOf<TrackSample>()
var maxElevA = 0.0
var maxElevB = 0.0
var tSample = refinedAos
while (tSample <= refinedLos) {
// Use getElevation for elevation, matching the main pass predictor; use
// getFullPosition only for azimuth needed by the radar plot.
val elevA = elevationDeg(sat, posA, tSample)
val elevB = elevationDeg(sat, posB, tSample)
val fullA = sat.getFullPosition(posA, tSample)
val fullB = sat.getFullPosition(posB, tSample)
if (elevA > maxElevA) maxElevA = elevA
if (elevB > maxElevB) maxElevB = elevB
samples.add(tSample to (elevA to elevB))
tracks.add(
TrackSample(
time = tSample,
azimuthA = fullA.azimuth * 180.0 / PI,
elevationA = elevA,
azimuthB = fullB.azimuth * 180.0 / PI,
elevationB = elevB
)
)
tSample += sampleInterval
}
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
return MutualPass(
catNum = sat.data.catnum,
name = sat.data.name,
startTime = refinedAos,
endTime = refinedLos,
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
elevationSamples = samples,
trackSamples = tracks
)
}
return null
}
/** Refine the AOS (goingUp=true) or LOS (goingUp=false) to ~1s precision at the 0° horizon. */
private fun refineEdge(
sat: OrbitalObject, posA: GeoPos, posB: GeoPos,
approxTime: Long, step: Long, goingUp: Boolean
): Long {
if (goingUp) {
// AOS: walk backward from approxTime to find the last sample where either is below
// the horizon, then AOS is the next step after that.
var t = approxTime
while (t > approxTime - 70_000L) {
val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t)
if (eA > 0.0 && eB > 0.0) {
t -= step
} else {
return t + step
}
}
return approxTime - 70_000L + step
} else {
// LOS: walk forward from approxTime to find the first sample where either drops
// below the horizon, then LOS is the step before that.
var t = approxTime
while (t < approxTime + 70_000L) {
val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t)
if (eA > 0.0 && eB > 0.0) {
t += step
} else {
return t - step
}
}
return approxTime + 70_000L - step
}
}
private fun elevationDeg(sat: OrbitalObject, pos: GeoPos, time: Long): Double {
return sat.getElevation(pos, time) * 180.0 / PI
}
private fun findNextMutualPass(
sat: OrbitalObject,
posA: GeoPos, posB: GeoPos,
startTime: Long, endTime: Long
): Pair<Long, Long>? {
var t = startTime
val step = 60_000L
// Skip an in-progress mutual window at the search start (same as getLeoPass):
// walk forward until either station drops below the horizon, then keep searching.
if (elevationDeg(sat, posA, t) > 0.0 && elevationDeg(sat, posB, t) > 0.0) {
while (t < endTime) {
if (elevationDeg(sat, posA, t) <= 0.0 || elevationDeg(sat, posB, t) <= 0.0) break
t += step
}
}
while (t < endTime) {
val elevA = elevationDeg(sat, posA, t)
val elevB = elevationDeg(sat, posB, t)
if (elevA > 0.0 && elevB > 0.0) {
var aos = t
var rew = t
while (rew > startTime - 600_000L) {
val eA = elevationDeg(sat, posA, rew)
val eB = elevationDeg(sat, posB, rew)
if (eA <= 0.0 || eB <= 0.0) {
aos = rew + step
break
}
rew -= step
}
var los = t
var fwd = t
while (fwd < endTime + 600_000L) {
val eA = elevationDeg(sat, posA, fwd)
val eB = elevationDeg(sat, posB, fwd)
if (eA <= 0.0 || eB <= 0.0) {
los = fwd
break
}
fwd += step
}
if (los > aos) return Pair(aos, los)
}
t += step
}
return null
}
fun clearError() = _uiState.update { it.copy(errorMessage = null) }
companion object {
fun factory(container: IMainContainer) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
MutualViewModel(
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
) as T
}
}
}
@@ -62,16 +62,9 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.domain.source.Sources
import kotlin.math.roundToInt
private val allModes = listOf(
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
)
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
private const val dayMinutes = 24 * 60
private const val minuteStep = 15
@@ -381,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allModes) { index, item ->
itemsIndexed(Sources.satelliteModes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -30,6 +30,7 @@ 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -147,6 +148,7 @@ private fun PassesScreen(
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
Spacer(modifier = Modifier.height(0.dp))
}
}
ScreenColumn(
@@ -154,7 +156,7 @@ private fun PassesScreen(
TopBar(
isVerticalLayout = isVerticalLayout,
startAction = {
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
@@ -163,7 +165,7 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
}
)
}
@@ -290,6 +292,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Preview(showBackground = true)
@Composable
private fun DeepSpacePassPreview() {
@@ -316,12 +323,13 @@ private fun PassItem(
isVerticalLayout: Boolean = true,
isUtc: Boolean = false
) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.getDefault()).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
@@ -341,6 +349,10 @@ private fun PassItem(
.padding(horizontal = horizontalPadding, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
@@ -350,7 +362,7 @@ private fun PassItem(
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.onSurface
)
val elevColor = elevationColor(pass.maxElevation)
Icon(
@@ -34,6 +34,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -41,6 +44,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -60,7 +64,7 @@ class PassesViewModel(
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
modes = settingsRepo.passesSettings.value.selectedModes,
modes = settingsRepo.selectedSatModes.value,
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
)
)
@@ -86,33 +90,43 @@ class PassesViewModel(
}
}
}
// Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch {
satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) {
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
val grouped = groupPasses(processed, isUtc)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
sunTimes = sunTimes,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
}
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(modes = modes) }
}
}
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
// computations run once per restart, progress/countdown are calculated every second.
viewModelScope.launch {
combine(
satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
val filtered = if (showDeepSpace) allPasses
else allPasses.filter { !it.isDeepSpace }
// Expensive: recompute sun times once per items/UTC change, not every second
val sunTimes = computeSunTimes(filtered, isUtc)
_uiState.update { it.copy(sunTimes = sunTimes) }
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow)
val grouped = groupPasses(processed, isUtc)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000.milliseconds)
}
}
}
}
fun onAction(action: PassesAction) {
@@ -127,21 +141,9 @@ class PassesViewModel(
aosStartMinute = action.aosStartMinute,
aosEndMinute = action.aosEndMinute,
invertAosTimeWindow = action.invertAosTimeWindow,
showDeepSpace = action.showDeepSpace,
modes = _uiState.value.modes
)
is PassesAction.FilterRadios ->
applyFilter(
hoursAhead = _uiState.value.hours,
minElevation = _uiState.value.elevation,
lowElevation = _uiState.value.lowElevation,
highElevation = _uiState.value.highElevation,
aosStartMinute = _uiState.value.aosStartMinute,
aosEndMinute = _uiState.value.aosEndMinute,
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
showDeepSpace = _uiState.value.showDeepSpace,
modes = action.modes
showDeepSpace = action.showDeepSpace
)
is PassesAction.FilterRadios -> setModesFilter(action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog ->
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
@@ -152,14 +154,20 @@ class PassesViewModel(
}
}
/** Returns a locale-appropriate date format for pass grouping headers. */
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
val pattern = if (Locale.getDefault().language == "zh") {
val locale = displayLocale()
val pattern = if (locale.language == Locale.CHINESE.language) {
"yyyy'年'M'月'd'日' EEEE"
} else {
"EEE, dd MMM yyyy"
}
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
}
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
@@ -167,7 +175,7 @@ class PassesViewModel(
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = dateFormat(tz)
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz }
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
@@ -245,8 +253,7 @@ class PassesViewModel(
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
showDeepSpace: Boolean,
modes: List<String>
showDeepSpace: Boolean
) = viewModelScope.launch {
settingsRepo.setPassesSettings(
PassesSettings(
@@ -255,8 +262,7 @@ class PassesViewModel(
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
modes
invertAosTimeWindow
)
)
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
@@ -269,10 +275,10 @@ class PassesViewModel(
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
invertAosTimeWindow = invertAosTimeWindow,
showDeepSpace = showDeepSpace,
modes = modes
showDeepSpace = showDeepSpace
)
}
val modes = settingsRepo.selectedSatModes.value
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = hoursAhead,
@@ -284,6 +290,20 @@ class PassesViewModel(
)
}
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
settingsRepo.setSelectedSatModes(modes)
_uiState.update { it.copy(modes = modes) }
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = _uiState.value.hours,
minElevation = _uiState.value.elevation,
aosStartMinute = _uiState.value.aosStartMinute,
aosEndMinute = _uiState.value.aosEndMinute,
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
modes = modes
)
}
private fun refreshPasses() = viewModelScope.launch {
val settings = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(
@@ -293,7 +313,7 @@ class PassesViewModel(
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
modes = settingsRepo.selectedSatModes.value
)
}
-4
View File
@@ -5,7 +5,3 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.radar"
}
dependencies {
implementation(project(":feature:mutual"))
}
@@ -60,7 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -73,11 +73,10 @@ import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Calculator("Calculator"),
Sstv("SSTV")
}
@@ -87,39 +86,17 @@ fun RadarDestination(navigateUp: () -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
val navigateUpAndClearMutual = {
if (container.mutualPassData.value.endTime > 0L) {
container.setMutualPassData(MutualPassData())
}
navigateUp()
}
LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) {
val remainingMs = mutualData.endTime - System.currentTimeMillis()
if (remainingMs <= 0L) {
navigateUpAndClearMutual()
return@LaunchedEffect
}
delay(remainingMs.coerceAtMost(1000L))
}
}
// Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
LaunchedEffect(hasPermission) {
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
viewModel.onAction(RadarAction.CwPermissionResult(hasPermission))
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}
@@ -129,37 +106,12 @@ private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
mutualData: MutualPassData,
requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
}
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
// at the current moment, same display mode as the local station.
val trackB = remember(mutualData.trackSamples) {
mutualData.trackSamples
.filter { it.elevationB > 0.0 }
.map {
OrbitalPos(
azimuth = it.azimuthB * PI / 180.0,
elevation = it.elevationB * PI / 180.0,
time = it.time
)
}
}
val timeNow = System.currentTimeMillis()
val trackBPosition = mutualData.trackSamples
.filter { it.time <= timeNow }
.lastOrNull()
?.let {
OrbitalPos(
azimuth = it.azimuthB * PI / 180.0,
elevation = it.elevationB * PI / 180.0,
time = it.time
)
}
Column(
modifier = Modifier
.layoutPadding()
@@ -183,11 +135,11 @@ private fun RadarScreen(
}
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
RadarCard(uiState, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
RadarCard(uiState, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
}
}
@@ -201,16 +153,31 @@ private fun PagerCard(
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
val lastPage = pages.lastIndex
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
}
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
selected = pagerState.currentPage == index,
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
@@ -222,14 +189,17 @@ private fun PagerCard(
) { pageIndex ->
when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
cw = uiState.cw,
radioControl = uiState.radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission
)
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Calculator -> CalculatorPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
onAction = onAction
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
@@ -243,12 +213,7 @@ private fun PagerCard(
}
@Composable
private fun RadarCard(
uiState: RadarState,
trackB: List<OrbitalPos> = emptyList(),
trackBPosition: OrbitalPos? = null,
modifier: Modifier = Modifier
) {
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val satellitePos = uiState.orbitalPos
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
// Always call these composables unconditionally — conditional composable calls violate
@@ -281,11 +246,10 @@ private fun RadarCard(
RadarViewCompose(
item = position,
items = uiState.satTrack,
trackB = trackB.takeIf { it.isNotEmpty() },
trackBPosition = trackBPosition,
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
shouldFlipRadar = uiState.shouldFlipRadar,
modifier = Modifier.align(Alignment.Center),
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
@@ -58,18 +58,12 @@ data class RadarState(
val satTrack: List<OrbitalPos> = emptyList(),
val shouldShowSweep: Boolean = false,
val shouldUseCompass: Boolean = false,
val shouldFlipRadar: Boolean = false,
val sunPosition: CelestialComputer.SunPosition? = null,
val moonPosition: CelestialComputer.MoonPosition? = null,
val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState(),
val cw: CwSubState = CwSubState(),
val mutualSamples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
val mutualStartTime: Long = 0L,
val mutualEndTime: Long = 0L,
val mutualMaxElev: Double = 10.0,
val mutualLabelA: String = "你",
val mutualLabelB: String = "友台"
val sstv: SstvSubState = SstvSubState()
)
enum class SstvStatus { Idle, Recording }
@@ -84,19 +78,6 @@ data class SstvSubState(
val diagnosticsMetrics: SstvQualityMetrics? = null
)
// --- CW Decoder ---
enum class CwStatus { Idle, Listening }
data class CwSubState(
val status: CwStatus = CwStatus.Idle,
val hasPermission: Boolean = false,
val decodedText: String = "",
val cwToneFreq: Float = 700f,
val isExpanded: Boolean = false,
val signalStrength: Float = 0f
)
sealed interface RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
data class SelectTransmitter(val uuid: String) : RadarAction
@@ -116,12 +97,4 @@ sealed interface RadarAction {
data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction
// CW actions
data object CwStartListening : RadarAction
data object CwStopListening : RadarAction
data object CwReset : RadarAction
data class CwSetToneFreq(val freq: Float) : RadarAction
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
data class CwPermissionResult(val granted: Boolean) : RadarAction
}
@@ -60,8 +60,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.predict.PI_2
import com.rtbishop.look4sat.core.domain.utility.toRadians
import com.rtbishop.look4sat.core.presentation.R
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
@@ -73,12 +71,10 @@ private const val SWEEP_DURATION_MS = 8_000
fun RadarViewCompose(
item: OrbitalPos,
items: List<OrbitalPos>,
trackB: List<OrbitalPos>? = null,
trackBColor: Color = MaterialTheme.colorScheme.tertiary,
trackBPosition: OrbitalPos? = null,
azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean,
shouldUseCompass: Boolean,
shouldFlipRadar: Boolean,
modifier: Modifier = Modifier,
sunPosition: CelestialComputer.SunPosition? = null,
moonPosition: CelestialComputer.MoonPosition? = null,
@@ -94,21 +90,14 @@ fun RadarViewCompose(
animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale"
)
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block.
// Only create the infinite transition when the sweep is enabled — otherwise the canvas would
// be invalidated every frame for an effect that isn't visible, wasting GPU and battery.
val sweepDegrees = if (shouldShowSweep) {
val sweepTransition = rememberInfiniteTransition(label = "sweep")
val animatedSweep by sweepTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
label = "sweepDegrees"
)
animatedSweep
} else {
0f
}
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
val sweepTransition = rememberInfiniteTransition(label = "sweep")
val sweepDegrees by sweepTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
label = "sweepDegrees"
)
val measurer = rememberTextMeasurer()
val sunPainter = painterResource(R.drawable.ic_sun)
val moonPainter = painterResource(R.drawable.ic_moon)
@@ -119,53 +108,32 @@ fun RadarViewCompose(
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// Station-B overlay track (dashed, with the same directional arrow stamp as station A)
var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
var trackBPath by remember { mutableStateOf(Path()) }
var trackBEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = size.minDimension / 2f * 0.95f
// Rebuild track path and sweep brush when canvas size or track data changes
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
if (radius != cachedRadius || items !== cachedItemsRef) {
trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath)
val newTrackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
trackBPath = newTrackBPath
trackBEffect = if (!newTrackBPath.isEmpty) createTrackEffect(newTrackBPath)
else PathEffect.cornerPathEffect(0f)
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius
cachedItemsRef = items
cachedTrackBRef = trackB
cachedSweepColor = primaryColor
} else if (primaryColor != cachedSweepColor) {
// Rebuild brush on theme change without waiting for a size change
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedSweepColor = primaryColor
}
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
rotate(rotation) {
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
drawRadar(radius, radarColor)
drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) {
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
// Station-B overlay: full dashed track + directional arrows + live position dot
// (same display mode as the local station: track line/arrow + pulsing dot)
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
drawPath(
trackBPath, trackBColor,
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
)
drawPath(trackBPath, trackBColor, style = Stroke(pathEffect = trackBEffect))
}
trackBPosition?.let { posB ->
if (posB.elevation > 0) {
drawPosition(posB, radius, animScale, trackBColor)
}
}
if (item.elevation > 0) {
drawPosition(item, radius, animScale, primaryColor)
}
@@ -240,25 +208,13 @@ private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, b
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
val trackPath = Path()
var lastAzim: Double? = null
positions.forEachIndexed { index, pos ->
val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
// Split the path when azimuth wraps 0°/360° to avoid a line across the plot
val wrap = lastAzim != null && abs(azimuthDeltaRad(pos.azimuth - lastAzim!!)) > PI
if (index == 0 || wrap) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
lastAzim = pos.azimuth
if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
}
return trackPath
}
/** Normalize an azimuth delta (radians) into the [-PI, PI] range. */
private fun azimuthDeltaRad(deltaRad: Double): Double {
var d = deltaRad % (2 * PI)
if (d > PI) d -= 2 * PI
if (d < -PI) d += 2 * PI
return d
}
private fun createTrackEffect(trackPath: Path): PathEffect {
val shapeRadius = 24f
val angle = 120.0.toRadians()
@@ -34,7 +34,6 @@ import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDecoder
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
@@ -68,11 +67,12 @@ class RadarViewModel(
private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
private var transponders: List<SatRadio> = emptyList()
private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null
private var cwDecoder: CwDecoder? = null
private var cwListeningJob: Job? = null
private var sensorCollectionJob: Job? = null
// Celestial positions change slowly, recompute at most once per minute
private var lastCelestialUpdateMs = 0L
@@ -91,33 +91,49 @@ class RadarViewModel(
val uiState: StateFlow<RadarState> = _uiState
init {
collectCompassSensor()
collectSettingsChanges()
collectSettingsChanges() // also handles initial sensor subscription
collectPassAndStartTickLoop()
collectRadioTrackingState()
}
private fun collectCompassSensor() {
if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch {
// Starts sensor collection if not already running.
private fun startSensorCollection() {
if (sensorCollectionJob?.isActive == true) return
sensorCollectionJob = viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
val orientationValues =
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
}
// Stops sensor collection and disables the hardware sensor.
private fun stopSensorCollection() {
sensorCollectionJob?.cancel()
sensorCollectionJob = null
sensorsRepo.disableSensor()
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
settingsRepo.otherSettings.collect { settings ->
compassOffset = settings.radarCompassOffset
compassOffsetElev = settings.radarCompassOffsetElev
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
shouldShowSweep = settings.stateOfSweep,
shouldUseCompass = settings.stateOfSensors
shouldUseCompass = settings.stateOfSensors,
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
)
}
// Reactively wire sensor hardware to the compass setting
when {
settings.stateOfSensors -> startSensorCollection()
else -> stopSensorCollection()
}
}
}
}
@@ -139,8 +155,6 @@ class RadarViewModel(
val passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
?: passes.find { it.catNum == catNum && aosTime in it.aosTime..it.losTime }
?: passes.find { it.catNum == catNum }
?: passes.firstOrNull()
}
@@ -181,7 +195,7 @@ class RadarViewModel(
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
)
}
processRadios(allRadios, pos)
processRadios(allRadios, pass.orbitalObject, timeNow)
sendPassData(pos)
}
@@ -218,7 +232,7 @@ class RadarViewModel(
}
override fun onCleared() {
sensorsRepo.disableSensor()
stopSensorCollection()
}
fun onAction(action: RadarAction) {
@@ -277,25 +291,6 @@ class RadarViewModel(
sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
}
// CW actions
is RadarAction.CwPermissionResult -> {
_uiState.update { it.copy(cw = it.cw.copy(hasPermission = action.granted)) }
if (action.granted) initCwDecoder()
}
RadarAction.CwStartListening -> startCwListening()
RadarAction.CwStopListening -> stopCwListening()
RadarAction.CwReset -> {
cwDecoder?.resetDecoder()
_uiState.update { it.copy(cw = it.cw.copy(decodedText = "")) }
}
is RadarAction.CwSetToneFreq -> {
_uiState.update { it.copy(cw = it.cw.copy(cwToneFreq = action.freq)) }
cwDecoder = CwDecoder(sampleRate = audioCapture.sampleRate, cwToneFreq = action.freq)
}
is RadarAction.CwToggleExpanded -> {
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
}
}
}
@@ -342,8 +337,8 @@ class RadarViewModel(
}
}
private suspend fun processRadios(radios: List<SatRadio>, satPos: OrbitalPos) {
val transmitters = satelliteRepo.getRadios(satPos, radios)
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
_uiState.update { state ->
val freq = if (state.transceivers.selectedUuid != null) {
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
@@ -416,52 +411,6 @@ class RadarViewModel(
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
}
private fun initCwDecoder() {
if (cwDecoder == null) {
cwDecoder = CwDecoder(
sampleRate = audioCapture.sampleRate,
cwToneFreq = _uiState.value.cw.cwToneFreq
)
}
}
private fun startCwListening() {
if (cwListeningJob?.isActive == true) return
if (!_uiState.value.cw.hasPermission) {
// Permission not yet granted — request it (launcher handles the result)
return
}
// Stop SSTV if running (audio capture is shared)
stopSstvRecording()
initCwDecoder()
cwDecoder?.resetDecoder()
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Listening)) }
cwListeningJob = viewModelScope.launch {
// Collect decoded text flow
launch {
cwDecoder?.decodedTextFlow?.collect { text ->
_uiState.update { it.copy(cw = it.cw.copy(decodedText = text)) }
}
}
// Collect signal strength
launch {
cwDecoder?.signalStrength?.collect { strength ->
_uiState.update { it.copy(cw = it.cw.copy(signalStrength = strength)) }
}
}
// Capture audio and feed to decoder
audioCapture.audioFlow().collect { buffer ->
cwDecoder?.processBuffer(buffer)
}
}
}
private fun stopCwListening() {
cwListeningJob?.cancel()
cwListeningJob = null
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
}
companion object {
// SSTV Decoder Tuning Parameters
// ==============================
@@ -469,7 +418,7 @@ class RadarViewModel(
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
// - 0.50-0.60: Conservative, best for noisy environments
private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
@@ -492,7 +441,7 @@ class RadarViewModel(
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
// Ask users to compare both if they report line-skipping/compression artifacts.
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
// Debug: Return scope visualization in frames (increases per-frame memory usage).
private const val SSTV_INCLUDE_SCOPE = false
@@ -504,7 +453,6 @@ class RadarViewModel(
218.1, 225.7, 233.6, 241.8, 250.3
)
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
RadarViewModel(
@@ -40,13 +40,10 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -82,11 +79,8 @@ import kotlin.time.Duration.Companion.milliseconds
fun TransceiversPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
if (transceivers.isEmpty()) {
@@ -109,11 +103,8 @@ fun TransceiversPage(
TransceiverItem(
radio = radio,
isExpanded = isExpanded,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission,
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
)
}
@@ -121,6 +112,69 @@ fun TransceiversPage(
}
}
@Composable
fun CalculatorPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
onAction: (RadarAction) -> Unit,
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
?: calculatorTransceivers.firstOrNull()
}
if (selectedTransceiver == null) {
EmptyTransceiversContent(modifier)
return
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (calculatorTransceivers.size > 1) {
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
item {
DopplerCalculatorPanel(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -148,11 +202,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
private fun TransceiverItem(
radio: SatRadio,
isExpanded: Boolean,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
onToggle: () -> Unit
) {
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
@@ -192,12 +243,8 @@ private fun TransceiverItem(
.rotate(if (isExpanded) 270f else 90f)
)
}
// Title with mode
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
val fullTitle = "$title ($mode)"
Text(
text = fullTitle,
text = transceiverTitle(radio),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -245,11 +292,8 @@ private fun TransceiverItem(
) {
ExpandedRadioControl(
radio = radio,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission
onAction = onAction
)
}
@@ -318,11 +362,8 @@ private fun UnifiedFrequencyRow(
@Composable
private fun ExpandedRadioControl(
radio: SatRadio,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
onAction: (RadarAction) -> Unit
) {
Column(
modifier = Modifier
@@ -395,10 +436,11 @@ private fun ExpandedRadioControl(
)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 5,
modifier = Modifier.fillMaxWidth()
) {
val chipModifier = Modifier.width(64.dp)
val chipModifier = Modifier.weight(1f)
FilterChip(
selected = radioControl.ctcssTone == null,
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
@@ -431,23 +473,6 @@ private fun ExpandedRadioControl(
}
}
// Doppler frequency calculator (linear transponders only)
DopplerFrequencyCalculator(
transponder = radio,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
// CW decoder panel (linear transponders only)
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
// Control buttons
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
@@ -480,116 +505,82 @@ private fun ExpandedRadioControl(
}
@Composable
private fun DopplerFrequencyCalculator(
private fun DopplerCalculatorPanel(
transponder: SatRadio,
orbitalPos: OrbitalPos?,
modifier: Modifier = Modifier
) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember { mutableStateOf("") }
var rxInputMHz by remember { mutableStateOf("") }
var offsetKHz by remember { mutableStateOf("") }
var lastEditedBy by remember { mutableStateOf(EditedField.TX) }
var txInputMHz by remember(transponder.uuid) { mutableStateOf("") }
var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") }
var offsetKHz by remember(transponder.uuid) { mutableStateOf("") }
var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L
fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0)
fun mhzToHz(text: String): Long? = text.toDoubleOrNull()
?.takeIf { it > 0.0 }
?.let { (it * 1_000_000).toLong() }
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field
LaunchedEffect(orbitalPos) {
fun calculateDownlink(txText: String, offsetText: String): String? {
val txHz = mhzToHz(txText) ?: return null
return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = txHz,
transponder = transponder,
orbitalPos = orbitalPos,
offsetHz = offsetHz(offsetText)
)?.let(::formatHz)
}
fun calculateUplink(rxText: String, offsetText: String): String? {
val rxHz = mhzToHz(rxText) ?: return null
return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = rxHz,
transponder = transponder,
orbitalPos = orbitalPos,
offsetHz = offsetHz(offsetText)
)?.let(::formatHz)
}
LaunchedEffect(orbitalPos, transponder.uuid) {
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
if (rxHz != null) {
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0)
}
}
calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it }
} else {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
if (txHz != null) {
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
}
}
calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it }
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.radar_doppler_calc),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.radar_doppler_info),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Offset input
OutlinedTextField(
value = offsetKHz,
onValueChange = { newVal ->
offsetKHz = newVal
// Trigger recompute based on last edited field
onValueChange = { newValue ->
offsetKHz = newValue
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
}
calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it }
} else {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
}
calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it }
}
},
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth()
)
// TX and RX inputs on the same row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
// TX input → compute RX
OutlinedTextField(
value = txInputMHz,
onValueChange = { newVal ->
txInputMHz = newVal
onValueChange = { newValue ->
txInputMHz = newValue
lastEditedBy = EditedField.TX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val txHz = (mhz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
rxInputMHz = ""
}
rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz
},
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
singleLine = true,
@@ -597,22 +588,12 @@ private fun DopplerFrequencyCalculator(
modifier = Modifier.weight(1f)
)
// RX input → compute TX
OutlinedTextField(
value = rxInputMHz,
onValueChange = { newVal ->
rxInputMHz = newVal
onValueChange = { newValue ->
rxInputMHz = newValue
lastEditedBy = EditedField.RX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val rxHz = (mhz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
txInputMHz = ""
}
txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz
},
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
singleLine = true,
@@ -625,120 +606,6 @@ private fun DopplerFrequencyCalculator(
private enum class EditedField { TX, RX }
@Composable
private fun CwDecoderPanel(
cw: CwSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Header row: expand/collapse toggle
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onAction(RadarAction.CwToggleExpanded(!cw.isExpanded)) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.radar_cw_decoder),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Icon(
painter = painterResource(id = R.drawable.ic_arrow),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
.rotate(if (cw.isExpanded) 270f else 90f)
)
}
AnimatedVisibility(visible = cw.isExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Control buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
if (cw.status == CwStatus.Idle) {
Button(
onClick = {
if (!cw.hasPermission) {
requestMicPermission()
} else {
onAction(RadarAction.CwStartListening)
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_start))
}
} else {
Button(
onClick = { onAction(RadarAction.CwStopListening) },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_stop))
}
}
OutlinedButton(
onClick = { onAction(RadarAction.CwReset) },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_reset))
}
}
// Signal strength indicator
if (cw.status == CwStatus.Listening && cw.signalStrength > 0f) {
val strengthPct = (cw.signalStrength * 100).toInt()
Text(
text = "Signal: $strengthPct%",
fontSize = 12.sp,
color = if (cw.signalStrength > 0.5f) Color(0xFF4CAF50)
else if (cw.signalStrength > 0.2f) Color(0xFFFFC107)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Decoded text output
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
) {
Text(
text = "Decoded:",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = cw.decodedText.ifEmpty { "Waiting for CW signal..." },
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
@Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let {
@@ -754,5 +621,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
)
}
private fun transceiverTitle(radio: SatRadio): String {
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
return "$title ($mode)"
}
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
-11
View File
@@ -1,11 +0,0 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.roaming"
}
dependencies {
implementation(project(":core:data"))
}
@@ -1,791 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.roaming
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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.core.content.ContextCompat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 漫游页 —— 原样照搬自 QTH定位器 2.0 (com.us1pm.gridsquarelocator)。
* UI: res/layout/main.xml (结构/尺寸/颜色逐项照搬, 不做任何更改)
* 逻辑: MainActivity.showLocation() / checkEnabled() / GPS 实时监听
* 红线: UI 与逻辑均不更改, 与成品截图逐像素对齐。
*/
@Composable
fun RoamingScreen() {
val context = LocalContext.current
var state by remember { mutableStateOf(RoamingState()) }
// 照搬 onCreate: 日期固定文本 + 小时前缀(定位时拼接分钟)
val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) }
var hourPrefix by remember { mutableStateOf("") }
// 照搬 onCreate: 初始 checkEnabled() -> 红点+设置按钮, 日期隐藏
var hasPermission by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { }
LaunchedEffect(Unit) {
hourPrefix = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 3)
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
hasPermission = fine || coarse
if (!hasPermission) {
permissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
)
} else {
// 照搬 onResume: 绿点+进度条+提示+日期显示, 红点/按钮隐藏
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
}
}
// 照搬 onResume/onPause: GPS + network 监听, 10 秒 / 10 米
val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager }
DisposableEffect(locationManager, hasPermission) {
if (hasPermission) {
@SuppressLint("MissingPermission")
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// 照搬 showLocation 开头: 只接受 gps / network provider
if (location.provider != "gps" && location.provider != "network") return
state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix)
.copy(date = dateText)
}
override fun onProviderDisabled(provider: String) {
// 照搬 checkEnabled: 红点+按钮, 日期隐藏
state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false)
}
override fun onProviderEnabled(provider: String) {
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
}
@Deprecated("Deprecated in Java")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
}
try {
locationManager.requestLocationUpdates("gps", 10_000L, 10f, listener)
locationManager.requestLocationUpdates("network", 10_000L, 10f, listener)
} catch (_: SecurityException) {
}
onDispose { locationManager.removeUpdates(listener) }
} else {
onDispose { }
}
}
// 照搬 res/layout/main.xml: 浅色页面, 蓝色信息区, 43sp 定位码, 3x3 九宫格, 底部版权
// 按用户要求: 上下往内缩进系统栏安全区, 避免内容被状态栏/导航栏遮挡
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.systemBars)
) {
// relativeLayout7: 顶部 GPS 状态条, 25dp 高, holo_blue_bright
Box(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.height(25.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// tvTitleGPS: "GPS" 14sp 黑, 左 8dp
Text(
text = "GPS",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (成品 main.xml)
Spacer(modifier = Modifier.width(15.dp))
// imGps: 绿点 (15dp, 主题色 primary -> 夜间滤镜下可见)
if (state.gpsOn) {
Box(
modifier = Modifier
.size(15.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
)
}
// imGpsOff: 红点叠层 (15dp, 主题色 error)
if (state.gpsOff) {
Box(
modifier = Modifier
.size(15.dp)
.background(MaterialTheme.colorScheme.error, CircleShape)
)
}
Spacer(modifier = Modifier.weight(1f))
// tvDate: 14sp 黑, 居中
if (state.showDate) {
Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.weight(1f))
// tvTime: 14sp 黑, 右对齐
Text(
text = state.time,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp)
)
}
// btnLocationSettings: 黄色按钮, 居中
// btnLocationSettings: 成品 #faf8f54a 半透明黄, 14sp 黑字, 居中
if (state.showSettings) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.primary)
.clickable { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
.padding(horizontal = 5.dp),
contentAlignment = Alignment.Center
) {
Text(text = "设置启用GPS", fontSize = 14.sp, color = MaterialTheme.colorScheme.onPrimary)
}
}
}
// linearLayout4: 经纬度区, holo_blue_bright
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// 纬度行: tvLati 16sp 黑 左 8dp | tvLat 右对齐 3dp
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.latLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.latValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
// 经度行
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.lonLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.lonValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
}
// relativeLayout6: 定位码区, 43sp bold 黑居中
Box(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = state.loc,
fontSize = 43.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
// progressBar: 加载中, 居中
if (state.showProgress) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant,
strokeWidth = 2.dp
)
}
// tvNotice: 提示文字, 居中顶部
if (state.showNotice) {
Text(
text = "请稍等,加载可能需要几分钟的时间。",
fontSize = 16.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp)
)
}
}
// relativeLayout: 九宫格, 占满剩余, 底部 15dp
// 用 Box 等价 RelativeLayout 布局: 左格贴左 / 中格水平居中 / 右格贴右 (成品 main.xml)
Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) {
// 行1: relativeLayout5 (上排: 纬度 +1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[0], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[2], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// 行2: relativeLayout4, 205dp 高 (中排: 中央)
Box(modifier = Modifier.fillMaxWidth().height(205.dp)) {
GridCell(text = state.grids[3], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
// 中央列: tv22 (100x80dp centerInParent) 叠在 linearLayout3 (200x200dp) 之上
Box(modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center)) {
// linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = 2.dp)
.size(width = 200.dp, height = 200.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// iv: 红点 10x10dp, top|left, leftMargin/topMargin 查表绝对定位
Image(
painter = painterResource(R.drawable.roam_pnt),
contentDescription = null,
modifier = Modifier
.size(10.dp)
.offset(x = state.markerLeft.dp, y = state.markerTop.dp)
)
}
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight(白)
Text(
text = state.grids[4],
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.size(width = 100.dp, height = 80.dp)
.align(Alignment.Center)
)
}
GridCell(text = state.grids[5], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// 行3: relativeLayout3 (下排: 纬度 -1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[6], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[8], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
}
// tv4: 底部版权, 10sp 黑 (往上拉, 底部预留空间避免被导航栏遮挡)
Text(
text = "制作:US1PM 汉化:BA7LCE",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
)
}
}
// 周边格: 60/200/60dp, #0BACF1, bold 居中, 2dp margin (照搬 tv11/tv13/tv21/tv23/tv31/tv33)
@Composable
private fun GridCell(text: String, modifier: Modifier) {
Box(
modifier = modifier
.padding(2.dp)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
/**
* 照搬 MainActivity.showLocation() 的状态。
* 网格/九宫格/红点查表与成品逐行一致。
*/
data class RoamingState(
val date: String = "",
val time: String = "",
val latLabel: String = "纬度",
val latValue: String = "",
val lonLabel: String = "经度",
val lonValue: String = "",
val loc: String = "",
val grids: List<String> = List(9) { "" },
/** 红点 leftMargin(dp), 经度第 3 对字符查表 */
val markerLeft: Int = 0,
/** 红点 topMargin(dp), 纬度第 3 对字符查表(屏幕 Y 反转) */
val markerTop: Int = 0,
val gpsOn: Boolean = false,
val gpsOff: Boolean = true,
val showSettings: Boolean = true,
val showProgress: Boolean = false,
val showNotice: Boolean = false,
val showDate: Boolean = false
)
/**
* 照搬 showLocation() 的完整计算: 时间/经纬度 DMS/8 位网格/九宫格/红点查表。
*/
fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState {
// 照搬: tvTime = 缓存小时前缀 + 定位时间分钟
val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime))
val time = hourPrefix + minute
// 方向字符
val ew = if (lon >= 0.0) "E" else "W"
val ns = if (lat >= 0.0) "N" else "S"
// 照搬: 纬度 DMS (String.valueOf 无补零)
val latAbs = if (lat < 0.0) -lat else lat
val latDeg = latAbs.toInt()
val latMinFull = (latAbs - latDeg) * 60.0
val latMin = latMinFull.toInt()
val latSec = ((latMinFull - latMin) * 60.0).toInt()
val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns"
val latValue = "$lat° "
// 照搬: 经度 DMS
val lonAbs = if (lon < 0.0) -lon else lon
val lonDeg = lonAbs.toInt()
val lonMinFull = (lonAbs - lonDeg) * 60.0
val lonMin = lonMinFull.toInt()
val lonSec = ((lonMinFull - lonMin) * 60.0).toInt()
val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew"
val lonValue = "$lon° "
// 照搬: 8 位网格编码 (区间查表)
val (str, str2, str3, str4) = encodeLon(lon)
val (str5, str6, str38, str9) = encodeLat(lat)
val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9
// 照搬: 九宫格 parseInt3 五分支
val grids = buildGrids(str, str5, str2, str6, str3, str38)
// 照搬: 红点查表 (第 3 对字符)
val markerLeft = lonMargin[str3.firstOrNull()] ?: 0
val markerTop = latMargin[str38.firstOrNull()] ?: 0
return RoamingState(
time = time,
latLabel = latLabel,
latValue = latValue,
lonLabel = lonLabel,
lonValue = lonValue,
loc = loc,
grids = grids,
markerLeft = markerLeft,
markerTop = markerTop,
gpsOn = true,
gpsOff = false,
showSettings = false,
showProgress = false,
showNotice = false,
showDate = true
)
}
// 红点 leftMargin 查表 (MainActivity 1286-1386 行): 经度第 3 对 a..x -> -2..190 dp
private val lonMargin = mapOf(
'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56,
'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128,
'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190
)
// 红点 topMargin 查表 (MainActivity 1315-1363 行): 纬度第 3 对 a..x -> 190..-2 dp (屏幕 Y 反转)
private val latMargin = mapOf(
'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137,
'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65,
'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2
)
// 照搬 311-611 行: 经度 -> (20°区字母, 2°数字, 2'字母, 30"数字)
private fun encodeLon(inputLon: Double): List<String> {
var longitude = inputLon
var d = 0.0
val str = when {
(longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" }
(longitude >= -160.0) && (longitude <= -140.0) -> { longitude += 160.0; "B" }
(longitude >= -140.0) && (longitude <= -120.0) -> { longitude += 140.0; "C" }
(longitude >= -120.0) && (longitude <= -100.0) -> { longitude += 120.0; "D" }
(longitude >= -100.0) && (longitude <= -80.0) -> { longitude += 100.0; "E" }
(longitude >= -80.0) && (longitude <= -60.0) -> { longitude += 80.0; "F" }
(longitude >= -60.0) && (longitude <= -40.0) -> { longitude += 60.0; "G" }
(longitude >= -40.0) && (longitude <= -20.0) -> { longitude += 40.0; "H" }
(longitude >= -20.0) && (longitude <= 0.0) -> { longitude += 20.0; "I" }
(longitude >= 0.0) && (longitude <= 20.0) -> "J"
(longitude >= 20.0) && (longitude <= 40.0) -> { longitude -= 20.0; "K" }
(longitude >= 40.0) && (longitude <= 60.0) -> { longitude -= 40.0; "L" }
(longitude >= 60.0) && (longitude <= 80.0) -> { longitude -= 60.0; "M" }
(longitude >= 80.0) && (longitude <= 100.0) -> { longitude -= 80.0; "N" }
(longitude >= 100.0) && (longitude <= 120.0) -> { longitude -= 100.0; "O" }
(longitude >= 120.0) && (longitude <= 140.0) -> { longitude -= 120.0; "P" }
(longitude >= 140.0) && (longitude <= 160.0) -> { longitude -= 140.0; "Q" }
(longitude >= 160.0) && (longitude <= 180.0) -> { longitude -= 160.0; "R" }
else -> { longitude = 0.0; " " }
}
val str2 = when {
((longitude >= 0.0) && (longitude <= 2.0)) || ((longitude >= -20.0) && (longitude <= -18.0)) -> { d = longitude * 60.0; "0" }
((longitude >= 2.0) && (longitude <= 4.0)) || ((longitude >= -18.0) && (longitude <= -16.0)) -> { d = (longitude - 2.0) * 60.0; "1" }
((longitude >= 4.0) && (longitude <= 6.0)) || ((longitude >= -16.0) && (longitude <= -14.0)) -> { d = (longitude - 4.0) * 60.0; "2" }
((longitude >= 6.0) && (longitude <= 8.0)) || ((longitude >= -14.0) && (longitude <= -12.0)) -> { d = (longitude - 6.0) * 60.0; "3" }
((longitude >= 8.0) && (longitude <= 10.0)) || ((longitude >= -12.0) && (longitude <= -10.0)) -> { d = (longitude - 8.0) * 60.0; "4" }
((longitude >= 10.0) && (longitude <= 12.0)) || ((longitude >= -10.0) && (longitude <= -8.0)) -> { d = (longitude - 10.0) * 60.0; "5" }
((longitude >= 12.0) && (longitude <= 14.0)) || ((longitude >= -8.0) && (longitude <= -6.0)) -> { d = (longitude - 12.0) * 60.0; "6" }
((longitude >= 14.0) && (longitude <= 16.0)) || ((longitude >= -6.0) && (longitude <= -4.0)) -> { d = (longitude - 14.0) * 60.0; "7" }
((longitude >= 16.0) && (longitude <= 18.0)) || ((longitude >= -4.0) && (longitude <= -2.0)) -> { d = (longitude - 16.0) * 60.0; "8" }
((longitude >= 18.0) && (longitude <= 20.0)) || ((longitude >= -2.0) && (longitude <= 0.0)) -> { d = (longitude - 18.0) * 60.0; "9" }
else -> { d = 0.0; " " }
}
val str3 = when {
((d >= 0.0) && (d <= 5.0)) || ((d >= -120.0) && (d <= -115.0)) -> { longitude = d * 60.0; "a" }
((d >= 5.0) && (d <= 10.0)) || ((d >= -115.0) && (d <= -110.0)) -> { longitude = (d - 5.0) * 60.0; "b" }
((d >= 10.0) && (d <= 15.0)) || ((d >= -110.0) && (d <= -105.0)) -> { longitude = (d - 10.0) * 60.0; "c" }
((d >= 15.0) && (d <= 20.0)) || ((d >= -105.0) && (d <= -100.0)) -> { longitude = (d - 15.0) * 60.0; "d" }
((d >= 20.0) && (d <= 25.0)) || ((d >= -100.0) && (d <= -95.0)) -> { longitude = (d - 20.0) * 60.0; "e" }
((d >= 25.0) && (d <= 30.0)) || ((d >= -95.0) && (d <= -90.0)) -> { longitude = (d - 25.0) * 60.0; "f" }
((d >= 30.0) && (d <= 35.0)) || ((d >= -90.0) && (d <= -85.0)) -> { longitude = (d - 30.0) * 60.0; "g" }
((d >= 35.0) && (d <= 40.0)) || ((d >= -85.0) && (d <= -80.0)) -> { longitude = (d - 35.0) * 60.0; "h" }
((d >= 40.0) && (d <= 45.0)) || ((d >= -80.0) && (d <= -75.0)) -> { longitude = (d - 40.0) * 60.0; "i" }
((d >= 45.0) && (d <= 50.0)) || ((d >= -75.0) && (d <= -70.0)) -> { longitude = (d - 45.0) * 60.0; "j" }
((d >= 50.0) && (d <= 55.0)) || ((d >= -70.0) && (d <= -65.0)) -> { longitude = (d - 50.0) * 60.0; "k" }
((d >= 55.0) && (d <= 60.0)) || ((d >= -65.0) && (d <= -60.0)) -> { longitude = (d - 55.0) * 60.0; "l" }
((d >= 60.0) && (d <= 65.0)) || ((d >= -60.0) && (d <= -55.0)) -> { longitude = (d - 60.0) * 60.0; "m" }
((d >= 65.0) && (d <= 70.0)) || ((d >= -55.0) && (d <= -50.0)) -> { longitude = (d - 65.0) * 60.0; "n" }
((d >= 70.0) && (d <= 75.0)) || ((d >= -50.0) && (d <= -45.0)) -> { longitude = (d - 70.0) * 60.0; "o" }
((d >= 75.0) && (d <= 80.0)) || ((d >= -45.0) && (d <= -40.0)) -> { longitude = (d - 75.0) * 60.0; "p" }
((d >= 80.0) && (d <= 85.0)) || ((d >= -40.0) && (d <= -35.0)) -> { longitude = (d - 80.0) * 60.0; "q" }
((d >= 85.0) && (d <= 90.0)) || ((d >= -35.0) && (d <= -30.0)) -> { longitude = (d - 85.0) * 60.0; "r" }
((d >= 90.0) && (d <= 95.0)) || ((d >= -30.0) && (d <= -25.0)) -> { longitude = (d - 90.0) * 60.0; "s" }
((d >= 95.0) && (d <= 100.0)) || ((d >= -25.0) && (d <= -20.0)) -> { longitude = (d - 95.0) * 60.0; "t" }
((d >= 100.0) && (d <= 105.0)) || ((d >= -20.0) && (d <= -15.0)) -> { longitude = (d - 100.0) * 60.0; "u" }
((d >= 105.0) && (d <= 110.0)) || ((d >= -15.0) && (d <= -10.0)) -> { longitude = (d - 105.0) * 60.0; "v" }
((d >= 110.0) && (d <= 115.0)) || ((d >= -10.0) && (d <= -5.0)) -> { longitude = (d - 110.0) * 60.0; "w" }
((d >= 115.0) && (d <= 120.0)) || ((d >= -5.0) && (d <= 0.0)) -> { longitude = (d - 115.0) * 60.0; "x" }
else -> { longitude = 0.0; " " }
}
val str4 = when {
((longitude >= 0.0) && (longitude <= 30.0)) || ((longitude >= -300.0) && (longitude <= -270.0)) -> "0"
((longitude >= 30.0) && (longitude <= 60.0)) || ((longitude >= -270.0) && (longitude <= -240.0)) -> "1"
((longitude >= 60.0) && (longitude <= 90.0)) || ((longitude >= -240.0) && (longitude <= -210.0)) -> "2"
((longitude >= 90.0) && (longitude <= 120.0)) || ((longitude >= -210.0) && (longitude <= -180.0)) -> "3"
((longitude >= 120.0) && (longitude <= 150.0)) || ((longitude >= -180.0) && (longitude <= -150.0)) -> "4"
((longitude >= 150.0) && (longitude <= 180.0)) || ((longitude >= -150.0) && (longitude <= -120.0)) -> "5"
((longitude >= 180.0) && (longitude <= 210.0)) || ((longitude >= -120.0) && (longitude <= -90.0)) -> "6"
((longitude >= 210.0) && (longitude <= 240.0)) || ((longitude >= -90.0) && (longitude <= -60.0)) -> "7"
((longitude >= 240.0) && (longitude <= 270.0)) || ((longitude >= -60.0) && (longitude <= -30.0)) -> "8"
((longitude >= 270.0) && (longitude <= 300.0)) || ((longitude >= -30.0) && (longitude <= 0.0)) -> "9"
else -> " "
}
return listOf(str, str2, str3, str4)
}
// 照搬 612-914 行: 纬度 -> (10°区字母, 1°数字, 1'字母, 15"数字)
private fun encodeLat(inputLat: Double): List<String> {
var latitude = inputLat
var d2 = 0.0
var d3 = 0.0
val str5 = when {
(latitude >= -90.0) && (latitude <= -80.0) -> { d2 = latitude + 90.0; "A" }
(latitude >= -80.0) && (latitude <= -70.0) -> { d2 = latitude + 80.0; "B" }
(latitude >= -70.0) && (latitude <= -60.0) -> { d2 = latitude + 70.0; "C" }
(latitude >= -60.0) && (latitude <= -50.0) -> { d2 = latitude + 60.0; "D" }
(latitude >= -50.0) && (latitude <= -40.0) -> { d2 = latitude + 50.0; "E" }
(latitude >= -40.0) && (latitude <= -30.0) -> { d2 = latitude + 40.0; "F" }
(latitude >= -30.0) && (latitude <= -20.0) -> { d2 = latitude + 30.0; "G" }
(latitude >= -20.0) && (latitude <= -10.0) -> { d2 = latitude + 20.0; "H" }
(latitude >= -10.0) && (latitude <= 0.0) -> { d2 = latitude + 10.0; "I" }
(latitude >= 0.0) && (latitude <= 10.0) -> { d2 = latitude; "J" }
(latitude >= 10.0) && (latitude <= 20.0) -> { d2 = latitude - 10.0; "K" }
(latitude >= 20.0) && (latitude <= 30.0) -> { d2 = latitude - 20.0; "L" }
(latitude >= 30.0) && (latitude <= 40.0) -> { d2 = latitude - 30.0; "M" }
(latitude >= 40.0) && (latitude <= 50.0) -> { d2 = latitude - 40.0; "N" }
(latitude >= 50.0) && (latitude <= 60.0) -> { d2 = latitude - 50.0; "O" }
(latitude >= 60.0) && (latitude <= 70.0) -> { d2 = latitude - 60.0; "P" }
(latitude >= 70.0) && (latitude <= 80.0) -> { d2 = latitude - 70.0; "Q" }
(latitude >= 80.0) && (latitude <= 90.0) -> { d2 = latitude - 80.0; "R" }
else -> { d2 = 0.0; " " }
}
var str6 = " "
when {
((d2 >= 0.0) && (d2 <= 1.0)) || ((d2 >= -10.0) && (d2 <= -9.0)) -> { d3 = d2 * 60.0; str6 = "0" }
((d2 >= 1.0) && (d2 <= 2.0)) || ((d2 >= -9.0) && (d2 <= -8.0)) -> { d3 = (d2 - 1.0) * 60.0; str6 = "1" }
((d2 >= 2.0) && (d2 <= 3.0)) || ((d2 >= -8.0) && (d2 <= -7.0)) -> { d3 = (d2 - 2.0) * 60.0; str6 = "2" }
((d2 >= 3.0) && (d2 <= 4.0)) || ((d2 >= -7.0) && (d2 <= -6.0)) -> { d3 = (d2 - 3.0) * 60.0; str6 = "3" }
((d2 >= 4.0) && (d2 <= 5.0)) || ((d2 >= -6.0) && (d2 <= -5.0)) -> { d3 = (d2 - 4.0) * 60.0; str6 = "4" }
((d2 >= 5.0) && (d2 <= 6.0)) || ((d2 >= -5.0) && (d2 <= -4.0)) -> { d3 = (d2 - 5.0) * 60.0; str6 = "5" }
((d2 >= 6.0) && (d2 <= 7.0)) || ((d2 >= -4.0) && (d2 <= -3.0)) -> { d3 = (d2 - 6.0) * 60.0; str6 = "6" }
((d2 >= 7.0) && (d2 <= 8.0)) || ((d2 >= -3.0) && (d2 <= -2.0)) -> { d3 = (d2 - 7.0) * 60.0; str6 = "7" }
((d2 >= 8.0) && (d2 <= 9.0)) || ((d2 >= -2.0) && (d2 <= -1.0)) -> { d3 = (d2 - 8.0) * 60.0; str6 = "8" }
((d2 >= 9.0) && (d2 <= 10.0)) || ((d2 >= -1.0) && (d2 <= 0.0)) -> { d3 = (d2 - 9.0) * 60.0; str6 = "9" }
}
val str38 = when {
((d3 >= 0.0) && (d3 <= 2.5)) || ((d3 >= -60.0) && (d3 <= -57.5)) -> { d2 = d3 * 60.0; "a" }
((d3 >= 2.5) && (d3 <= 5.0)) || ((d3 >= -57.5) && (d3 <= -55.0)) -> { d2 = (d3 - 2.5) * 60.0; "b" }
((d3 >= 5.0) && (d3 <= 7.5)) || ((d3 >= -55.0) && (d3 <= -52.5)) -> { d2 = (d3 - 5.0) * 60.0; "c" }
((d3 >= 7.5) && (d3 <= 10.0)) || ((d3 >= -52.5) && (d3 <= -50.0)) -> { d2 = (d3 - 7.5) * 60.0; "d" }
((d3 >= 10.0) && (d3 <= 12.5)) || ((d3 >= -50.0) && (d3 <= -47.5)) -> { d2 = (d3 - 10.0) * 60.0; "e" }
((d3 >= 12.5) && (d3 <= 15.0)) || ((d3 >= -47.5) && (d3 <= -45.0)) -> { d2 = (d3 - 12.5) * 60.0; "f" }
((d3 >= 15.0) && (d3 <= 17.5)) || ((d3 >= -45.0) && (d3 <= -42.5)) -> { d2 = (d3 - 15.0) * 60.0; "g" }
((d3 >= 17.5) && (d3 <= 20.0)) || ((d3 >= -42.5) && (d3 <= -40.0)) -> { d2 = (d3 - 17.5) * 60.0; "h" }
((d3 >= 20.0) && (d3 <= 22.5)) || ((d3 >= -40.0) && (d3 <= -37.5)) -> { d2 = (d3 - 20.0) * 60.0; "i" }
((d3 >= 22.5) && (d3 <= 25.0)) || ((d3 >= -37.5) && (d3 <= -35.0)) -> { d2 = (d3 - 22.5) * 60.0; "j" }
((d3 >= 25.0) && (d3 <= 27.5)) || ((d3 >= -35.0) && (d3 <= -32.5)) -> { d2 = (d3 - 25.0) * 60.0; "k" }
((d3 >= 27.5) && (d3 <= 30.0)) || ((d3 >= -32.5) && (d3 <= -30.0)) -> { d2 = (d3 - 27.5) * 60.0; "l" }
((d3 >= 30.0) && (d3 <= 32.5)) || ((d3 >= -30.0) && (d3 <= -27.5)) -> { d2 = (d3 - 30.0) * 60.0; "m" }
((d3 >= 32.5) && (d3 <= 35.0)) || ((d3 >= -27.5) && (d3 <= -25.0)) -> { d2 = (d3 - 32.5) * 60.0; "n" }
((d3 >= 35.0) && (d3 <= 37.5)) || ((d3 >= -25.0) && (d3 <= -22.5)) -> { d2 = (d3 - 35.0) * 60.0; "o" }
((d3 >= 37.5) && (d3 <= 40.0)) || ((d3 >= -22.5) && (d3 <= -20.0)) -> { d2 = (d3 - 37.5) * 60.0; "p" }
((d3 >= 40.0) && (d3 <= 42.5)) || ((d3 >= -20.0) && (d3 <= -17.5)) -> { d2 = (d3 - 40.0) * 60.0; "q" }
((d3 >= 42.5) && (d3 <= 45.0)) || ((d3 >= -17.5) && (d3 <= -15.0)) -> { d2 = (d3 - 42.5) * 60.0; "r" }
((d3 >= 45.0) && (d3 <= 47.5)) || ((d3 >= -15.0) && (d3 <= -12.5)) -> { d2 = (d3 - 45.0) * 60.0; "s" }
((d3 >= 47.5) && (d3 <= 50.0)) || ((d3 >= -12.5) && (d3 <= -10.0)) -> { d2 = (d3 - 47.5) * 60.0; "t" }
((d3 >= 50.0) && (d3 <= 52.5)) || ((d3 >= -10.0) && (d3 <= -7.5)) -> { d2 = (d3 - 50.0) * 60.0; "u" }
((d3 >= 52.5) && (d3 <= 55.0)) || ((d3 >= -7.5) && (d3 <= -5.0)) -> { d2 = (d3 - 52.5) * 60.0; "v" }
((d3 >= 55.0) && (d3 <= 57.5)) || ((d3 >= -5.0) && (d3 <= -2.5)) -> { d2 = (d3 - 55.0) * 60.0; "w" }
((d3 >= 57.5) && (d3 <= 60.0)) || ((d3 >= -2.5) && (d3 <= 0.0)) -> { d2 = (d3 - 57.5) * 60.0; "x" }
else -> { d2 = 0.0; " " }
}
val str9 = when {
((d2 >= 0.0) && (d2 <= 15.0)) || ((d2 >= -150.0) && (d2 <= -135.0)) -> "0"
((d2 >= 15.0) && (d2 <= 30.0)) || ((d2 >= -135.0) && (d2 <= -120.0)) -> "1"
((d2 >= 30.0) && (d2 <= 45.0)) || ((d2 >= -120.0) && (d2 <= -105.0)) -> "2"
((d2 >= 45.0) && (d2 <= 60.0)) || ((d2 >= -105.0) && (d2 <= -90.0)) -> "3"
((d2 >= 60.0) && (d2 <= 75.0)) || ((d2 >= -90.0) && (d2 <= -75.0)) -> "4"
((d2 >= 75.0) && (d2 <= 90.0)) || ((d2 >= -75.0) && (d2 <= -60.0)) -> "5"
((d2 >= 90.0) && (d2 <= 105.0)) || ((d2 >= -60.0) && (d2 <= -45.0)) -> "6"
((d2 >= 105.0) && (d2 <= 120.0)) || ((d2 >= -45.0) && (d2 <= -30.0)) -> "7"
((d2 >= 120.0) && (d2 <= 135.0)) || ((d2 >= -30.0) && (d2 <= -15.0)) -> "8"
((d2 >= 135.0) && (d2 <= 150.0)) || ((d2 >= -15.0) && (d2 <= 0.0)) -> "9"
else -> " "
}
return listOf(str5, str6, str38, str9)
}
/**
* 照搬 917-1283 行: 九宫格 parseInt3 五分支(内部/西界/东界/北界/南界 + 四角)。
* 返回 9 格文本: [上左, 上中, 上右, 左中, 中央, 右中, 下左, 下中, 下右]。
*/
private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List<String> {
val parseInt = str2.toInt()
val parseInt2 = str6.toInt()
val str39 = str + str5
val str40 = str2 + str6
val parseInt3 = str40.toInt()
val str41 = str39 + str40
val g = MutableList(9) { "" }
fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString()
if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 内部: 数字对 ±1
val i7 = parseInt3 + 1
val i8 = i7 - 10
val valueOf9 = fmt2(i8)
val str11 = i7.toString()
val valueOf18 = (i7 + 10).toString()
val i9 = parseInt3 - 10
val valueOf10 = fmt2(i9)
val valueOf19 = (parseInt3 + 10).toString()
val i10 = parseInt3 - 1
val i11 = i10 - 10
val valueOf11 = fmt2(i11)
val str14 = i10.toString()
val str15 = (i10 + 10).toString()
g[0] = str39 + valueOf9
g[1] = str39 + str11
g[2] = str39 + valueOf18
g[3] = str39 + valueOf10
g[4] = str41
g[5] = str39 + valueOf19
g[6] = str39 + valueOf11
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 西边界: 经度 0 -> 9, 经度字母 -1
val str43 = (str[0] - 1).toString() + str5
val str44 = "9" + (parseInt2 + 1)
val i12 = parseInt3 + 1
val valueOf7 = fmt2(i12)
val str12 = (i12 + 10).toString()
val sb4 = "9" + parseInt2
val valueOf20 = (parseInt3 + 10).toString()
val str46 = "9" + (parseInt2 - 1)
val i13 = parseInt3 - 1
val valueOf8 = fmt2(i13)
val str15 = (i13 + 10).toString()
g[0] = str43 + str44
g[1] = str39 + valueOf7
g[2] = str39 + str12
g[3] = str43 + sb4
g[4] = str41
g[5] = str39 + valueOf20
g[6] = str43 + str46
g[7] = str39 + valueOf8
g[8] = str39 + str15
} else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 东边界: 经度 9 -> 0, 经度字母 +1
val str47 = (str[0] + 1).toString() + str5
val i14 = parseInt3 + 1
val valueOf21 = (i14 - 10).toString()
val valueOf22 = i14.toString()
val str48 = "0" + (parseInt2 + 1)
val valueOf23 = (parseInt3 - 10).toString()
val str49 = "0" + parseInt2
val i15 = parseInt3 - 1
val valueOf24 = (i15 - 10).toString()
val valueOf25 = i15.toString()
val str15 = "0" + (parseInt2 - 1)
g[0] = str39 + valueOf21
g[1] = str39 + valueOf22
g[2] = str47 + str48
g[3] = str39 + valueOf23
g[4] = str41
g[5] = str47 + str49
g[6] = str39 + valueOf24
g[7] = str39 + valueOf25
g[8] = str47 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) {
// 北边界: 纬度 9 -> 0, 纬度字母 +1
val str50 = str + (str5[0] + 1).toString()
val i16 = parseInt3 - 9
val i17 = i16 - 10
val valueOf4 = fmt2(i17)
val valueOf26 = i16.toString()
val valueOf27 = (i16 + 10).toString()
val i18 = parseInt3 - 10
val valueOf5 = fmt2(i18)
val valueOf28 = (parseInt3 + 10).toString()
val i19 = parseInt3 - 1
val i20 = i19 - 10
val valueOf6 = fmt2(i20)
val str14 = i19.toString()
val str15 = (i19 + 10).toString()
g[0] = str39 + valueOf4
g[1] = str50 + valueOf26
g[2] = str39 + valueOf27
g[3] = str39 + valueOf5
g[4] = str41
g[5] = str39 + valueOf28
g[6] = str39 + valueOf6
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) {
// 南边界: 纬度 0 -> 9, 纬度字母 -1
val str52 = str + (str5[0] - 1).toString()
val i21 = parseInt3 + 1
val i22 = i21 - 10
val valueOf = fmt2(i22)
val valueOf29 = i21.toString()
val valueOf30 = (i21 + 10).toString()
val i23 = parseInt3 - 10
val valueOf2 = fmt2(i23)
val valueOf31 = (parseInt3 + 10).toString()
val i24 = parseInt3 - 1
val valueOf3 = fmt2(i24)
val str14 = (i24 + 10).toString()
val str15 = (i24 + 20).toString()
g[0] = str39 + valueOf
g[1] = str39 + valueOf29
g[2] = str39 + valueOf30
g[3] = str39 + valueOf2
g[4] = str41
g[5] = str39 + valueOf31
g[6] = str52 + valueOf3
g[7] = str52 + str14
g[8] = str52 + str15
} else if (parseInt3 == 0) {
// 四角 00: 经纬度数字均 -1 进位
val v32 = (str[0] - 1).toString()
val v33 = (str5[0] - 1).toString()
g[0] = v32 + str5 + "91"
g[1] = str39 + "01"
g[2] = str39 + "11"
g[3] = v32 + str5 + "90"
g[4] = str41
g[5] = v32 + str5 + "10"
g[6] = v32 + v33 + "99"
g[7] = str + v33 + "09"
g[8] = str + v33 + "19"
} else if (parseInt3 == 9) {
// 四角 09: 经度 -1, 纬度 +1
val v34 = (str[0] - 1).toString()
val v35 = (str5[0] + 1).toString()
g[0] = v34 + v35 + "90"
g[1] = str + v35 + "00"
g[2] = str + v35 + "10"
g[3] = v34 + str5 + "99"
g[4] = str41
g[5] = v34 + str5 + "19"
g[6] = v34 + str5 + "98"
g[7] = v34 + str5 + "08"
g[8] = v34 + str5 + "18"
} else if (parseInt3 == 99) {
// 四角 99: 经纬度数字均 +1 进位
val v36 = (str[0] + 1).toString()
val v37 = (str5[0] + 1).toString()
g[0] = str + v37 + "80"
g[1] = str + v37 + "90"
g[2] = v36 + v37 + "00"
g[3] = str39 + "89"
g[4] = str41
g[5] = v36 + str5 + "09"
g[6] = str39 + "88"
g[7] = str39 + "98"
g[8] = v36 + str5 + "08"
} else if (parseInt3 == 90) {
// 四角 90: 经度 +1, 纬度 -1
val v38 = (str[0] + 1).toString()
val v39 = (str5[0] - 1).toString()
g[0] = str39 + "81"
g[1] = str39 + "91"
g[2] = v38 + str5 + "01"
g[3] = str39 + "80"
g[4] = str41
g[5] = v38 + str5 + "00"
g[6] = str + v39 + "89"
g[7] = str + v39 + "99"
g[8] = v38 + v39 + "09"
}
return g
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 B

@@ -1,124 +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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.roaming
import org.junit.Assert.assertEquals
import org.junit.Test
class RoamingStateTest {
// 用户实测坐标 (QTH定位器 2.0 成品截图): 22.314066 / 108.706575
private fun stateOf(lat: Double, lon: Double) =
roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:")
@Test
fun `user coordinates produce OL42ih45`() {
val state = stateOf(22.314066, 108.706575)
assertEquals("OL42ih45", state.loc)
}
@Test
fun `red marker from ih pair matches reference lookup`() {
val state = stateOf(22.314066, 108.706575)
// 成品查表: 经度第3对 'i' -> leftMargin 65dp, 纬度第3对 'h' -> topMargin 137dp
assertEquals(65, state.markerLeft)
assertEquals(137, state.markerTop)
}
@Test
fun `grid nine from OL42 matches reference`() {
val state = stateOf(22.314066, 108.706575)
val expected = listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)
assertEquals(expected, state.grids)
}
@Test
fun `latitude dms formatting matches reference`() {
val state = stateOf(22.314066, 108.706575)
assertEquals("纬度 22° 18' 50\" N", state.latLabel)
assertEquals("22.314066° ", state.latValue)
assertEquals("经度 108° 42' 23\" E", state.lonLabel)
assertEquals("108.706575° ", state.lonValue)
}
@Test
fun `time is hour prefix plus fix minutes`() {
// fixTime = 2026-08-03 21:12:13 UTC+8 -> minutes 12
val fix = 1785820333000L // 2026-08-03 21:12:13 +0800
val state = roamingStateFromLocation(22.314066, 108.706575, fix, "21:")
assertEquals("21:12", state.time)
}
@Test
fun `west edge borrows lon letter`() {
// 经度 0.5 纬度 22.314066: 数字对 02, 西边界 parseInt==0
val state = stateOf(22.314066, 0.5)
assertEquals(8, state.loc.length)
// 左列经度 0->9, 经度字母 J-1=I: IL92
assertEquals("IL92", state.grids[3])
}
@Test
fun `north edge carries lat letter`() {
// 纬度 29.5 经度 108.706575: 数字对 49? -> 纬度数字 9 -> 北边界
val state = stateOf(29.5, 108.706575)
assertEquals(8, state.loc.length)
// 上排纬度 +1 -> 0, 纬度字母 +1 (M)
assertEquals("OM40", state.grids[1])
}
@Test
fun `east edge carries lon letter`() {
// 经度 119.5 纬度 22.314066: 经度数字 9 -> 东边界
val state = stateOf(22.314066, 119.5)
assertEquals(8, state.loc.length)
// 右上 经度 9->0, 经度字母 O+1=P, 纬度 2+1=3: PL03
assertEquals("PL03", state.grids[2])
}
@Test
fun `south edge borrows lat letter`() {
// 纬度 0.5 经度 108.706575: 纬度数字 0 -> 南边界
val state = stateOf(0.5, 108.706575)
assertEquals(8, state.loc.length)
// 下中 纬度 0->9, 纬度字母 J-1=I: OI49
assertEquals("OI49", state.grids[7])
}
@Test
fun `boundary values wrap like reference`() {
// (90, 180) -> RR99xx99 (成品区间查表行为)
val state = stateOf(90.0, 180.0)
assertEquals("RR99xx99", state.loc)
}
@Test
fun `gps state after fix matches showLocation`() {
val state = stateOf(22.314066, 108.706575)
assertEquals(true, state.gpsOn)
assertEquals(false, state.gpsOff)
assertEquals(false, state.showSettings)
assertEquals(false, state.showProgress)
assertEquals(false, state.showNotice)
assertEquals(true, state.showDate)
}
}
@@ -45,18 +45,18 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog
@Preview(showBackground = true)
@Composable
private fun MultiTypesDialogPreview() {
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
private fun MultiModesDialogPreview() {
val modes = listOf("FM", "APT", "SSTV", "BPSK")
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
}
@Composable
internal fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
internal fun MultiModesDialog(
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) {
val selected = remember { mutableStateOf(types.toSet()) }
val toggle = { type: String ->
selected.value = if (type in selected.value) selected.value - type else selected.value + type
val selected = remember { mutableStateOf(modes.toSet()) }
val toggle = { mode: String ->
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
}
val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
@@ -68,7 +68,7 @@ internal fun MultiTypesDialog(
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allTypes) { index, item ->
itemsIndexed(allModes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -87,11 +87,11 @@ private fun SatellitesScreen(
navigateUp: () -> Unit
) {
if (uiState.isDialogShown) {
MultiTypesDialog(
allTypes = uiState.typesList,
types = uiState.currentTypes,
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
accept = { onAction(SatellitesAction.SelectTypes(it)) }
MultiModesDialog(
allModes = uiState.modesList,
modes = uiState.currentModes,
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
accept = { onAction(SatellitesAction.SelectModes(it)) }
)
}
if (uiState.shouldSeeWarning) {
@@ -108,6 +108,7 @@ private fun SatellitesScreen(
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -122,9 +123,9 @@ private fun SatellitesScreen(
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
TopBar {
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
ModeCard(
modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f)
)
PrimaryIconCard(
@@ -156,9 +157,9 @@ private fun SatellitesScreen(
resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd }
)
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
ModeCard(
modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f)
)
SearchBar(
@@ -240,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
}
@Composable
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
ElevatedCard(modifier = modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -251,12 +252,12 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_tags),
painter = painterResource(id = R.drawable.ic_radios),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.sat_type_hint, typesText),
text = stringResource(R.string.sat_type_hint, modesText),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
@@ -290,17 +291,14 @@ private fun Satellite(
.background(MaterialTheme.colorScheme.surface)
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(text = "$passSatId - ", color = MaterialTheme.colorScheme.primary)
Text(
text = item.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.onSurface
)
Checkbox(
checked = item.isSelected,
@@ -325,9 +323,54 @@ private fun SatellitesPreview() {
@Composable
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
val selectedItems = items.filter { it.isSelected }
val availableItems = items.filterNot { it.isSelected }
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
stickyHeader(key = "sat_summary_header") {
SummaryHeader(
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
)
}
if (selectedItems.isNotEmpty()) {
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
if (availableItems.isNotEmpty()) {
items(items = availableItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
}
}
@Composable
private fun SummaryHeader(selectedText: String, availableText: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = selectedText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = availableText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -24,8 +24,8 @@ data class SatellitesState(
val isLoading: Boolean = true,
val shouldSeeWarning: Boolean = false,
val itemsList: List<SatItem> = emptyList(),
val currentTypes: List<String> = emptyList(),
val typesList: List<String> = emptyList()
val currentModes: List<String> = emptyList(),
val modesList: List<String> = emptyList()
)
sealed interface SatellitesAction {
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction
data object ToggleTypesDialog : SatellitesAction
data class SelectModes(val modes: List<String>) : SatellitesAction
data object ToggleModesDialog : SatellitesAction
data object UnselectAll : SatellitesAction
}
@@ -35,13 +35,13 @@ class SatellitesViewModel(
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val defaultTypes = selectionRepo.getCurrentTypes()
private val defaultModes = selectionRepo.getCurrentModes()
private val _uiState = MutableStateFlow(
SatellitesState(
isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
currentTypes = defaultTypes,
typesList = selectionRepo.getTypesList()
currentModes = defaultModes,
modesList = selectionRepo.getModesList()
)
)
val uiState: StateFlow<SatellitesState> = _uiState
@@ -49,7 +49,7 @@ class SatellitesViewModel(
init {
viewModelScope.launch {
selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes)
selectionRepo.setModes(defaultModes)
selectionRepo.getEntriesFlow().collectLatest { items ->
_uiState.update { it.copy(isLoading = false, itemsList = items) }
}
@@ -59,6 +59,11 @@ class SatellitesViewModel(
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
}
}
viewModelScope.launch {
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(currentModes = modes) }
}
}
}
fun onAction(action: SatellitesAction) {
@@ -68,8 +73,8 @@ class SatellitesViewModel(
is SatellitesAction.SearchFor -> searchFor(action.query)
SatellitesAction.SelectAll -> selectAll(true)
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
is SatellitesAction.SelectTypes -> selectTypes(action.types)
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
is SatellitesAction.SelectModes -> selectModes(action.modes)
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
SatellitesAction.UnselectAll -> selectAll(false)
}
}
@@ -86,12 +91,12 @@ class SatellitesViewModel(
selectionRepo.setSelection(listOf(id), isTicked.not())
}
private fun selectTypes(types: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types)
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
private fun selectModes(modes: List<String>) = viewModelScope.launch {
selectionRepo.setModes(modes)
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
}
private fun toggleTypesDialog() {
private fun toggleModesDialog() {
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
}
@@ -17,12 +17,9 @@
*/
package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -30,26 +27,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing
@@ -70,18 +64,27 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
val titleText = stringResource(id = R.string.prefs_station_title)
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large)
) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -104,12 +107,18 @@ private fun LocatorDialogPreview() {
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) }
val onAccept = { save(locator.value).also { dismiss() } }
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
SharedDialog(
title = stringResource(R.string.prefs_locator_title),
onCancel = dismiss,
onAccept = onAccept
) {
OutlinedTextField(
value = locator.value,
onValueChange = { locator.value = it },
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large),
)
Spacer(modifier = Modifier.height(0.dp))
}
@@ -151,7 +160,12 @@ fun DataSourcesDialog(
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
val onAccept = {
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
onSave(
isEnabledCustomTle.value,
isEnabledCustomTransceivers.value,
urlTle.value,
urlTransceivers.value
)
onDismiss()
}
val onCancel = { onDismiss() }
@@ -207,17 +221,6 @@ fun DataSourcesDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
trailingIcon = {
IconButton(
onClick = { urlTle.value = Sources.defaultTleUrl },
enabled = isEnabledCustomTle.value
) {
Icon(
painter = painterResource(id = R.drawable.ic_reset),
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
)
}
},
)
Spacer(modifier = Modifier.height(6.dp))
Row(
@@ -247,17 +250,6 @@ fun DataSourcesDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
trailingIcon = {
IconButton(
onClick = { urlTransceivers.value = Sources.defaultTransceiversUrl },
enabled = isEnabledCustomTransceivers.value
) {
Icon(
painter = painterResource(id = R.drawable.ic_reset),
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
)
}
},
)
Spacer(modifier = Modifier.height(12.dp))
}
@@ -403,11 +395,15 @@ fun BluetoothOutputDialog(
) {
val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val frequencyState =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = {
onSave(
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
@@ -496,23 +492,22 @@ private fun OutputChannelSection(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RadioControlDialog(
initialSettings: RadioControlSettings,
pairedBluetoothDevices: List<Pair<String, String>>,
onDismiss: () -> Unit,
onSave: (RadioControlSettings) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val padding = LocalSpacing.current.large
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val padding = LocalSpacing.current.large
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
@@ -522,157 +517,157 @@ fun RadioControlDialog(
if (!isIcom && splitMode.value) splitMode.value = false
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
else RadioControlSettings.BAUD_RATES_YAESU
else RadioControlSettings.BAUD_RATES_YAESU
// If current baud rate is not in the new list, default to the first available
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
val pairedDevices: List<Pair<String, String>> = remember {
buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach {
add(Pair(it.name ?: "Unknown", it.address ?: ""))
}
} catch (_: SecurityException) { }
}
}
val onAccept = {
onSave(
RadioControlSettings(
enabled = enabled.value,
radioModel = radioModel.value,
enabled = enabled.value,
radioModel = radioModel.value,
txRadioAddress = txAddress.value,
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
txRadioName = txName.value,
rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue,
splitMode = splitMode.value
txRadioName = txName.value,
rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue,
splitMode = splitMode.value
)
)
onDismiss()
}
SharedDialog(
title = stringResource(R.string.rc_settings_title),
title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss,
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
// Enable switch
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.rc_enable_switch))
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
}
Spacer(modifier = Modifier.height(6.dp))
// Radio model — FlowRow so chips wrap on small screens
Text(
text = stringResource(R.string.rc_radio_model),
text = stringResource(R.string.rc_radio_model),
fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth()
) {
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
FilterChip(
selected = radioModel.value == model,
onClick = { radioModel.value = model },
label = { Text(model, fontSize = 12.sp) },
enabled = enabled.value
onClick = { radioModel.value = model },
label = {
Text(
text = compactRadioModelLabel(model),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
// IC-705 split-mode toggle (only shown for IC-705)
if (isIcom) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
Text(
text = "Use VFO-A/B split on one IC-705 instead of two radios",
fontSize = 12.sp,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = splitMode.value,
onCheckedChange = { splitMode.value = it },
enabled = enabled.value
)
}
Spacer(modifier = Modifier.height(6.dp))
val isSplitModeAvailable = enabled.value && isIcom
val splitModeLabelColor = if (isSplitModeAvailable) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
}
// TX Radio (always shown; in split mode this is the single IC-705)
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
Text(txLabel, fontWeight = FontWeight.Medium)
if (txAddress.value.isNotBlank()) {
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
}
CardButton(
onClick = { selectingFor.value = "tx" },
text = if (isSingleRadio) "Select Device" else "Select TX Device",
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
)
) {
Text(
text = "Split mode (IC-705 only)",
fontWeight = FontWeight.Medium,
color = splitModeLabelColor
)
Switch(
checked = splitMode.value,
onCheckedChange = { splitMode.value = it },
enabled = isSplitModeAvailable
)
}
Spacer(modifier = Modifier.height(6.dp))
// RX Radio (hidden in split mode — the same radio handles both)
if (!isSingleRadio) {
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
if (rxAddress.value.isNotBlank()) {
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
}
Text("Radio devices", fontWeight = FontWeight.Medium)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { selectingFor.value = "rx" },
text = "Select RX Device",
modifier = Modifier.fillMaxWidth()
onClick = { selectingFor.value = "tx" },
text = if (isSingleRadio) "Select Radio" else "Select TX",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
text = if (isSingleRadio) "RX = TX" else "Select RX",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.height(6.dp))
}
if (txAddress.value.isNotBlank()) {
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
}
if (isSingleRadio && txAddress.value.isNotBlank()) {
Text("RX: same as TX", fontSize = 13.sp)
} else if (rxAddress.value.isNotBlank()) {
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
}
Spacer(modifier = Modifier.height(6.dp))
// Paired device picker (inline, shown while selecting)
if (selectingFor.value.isNotBlank()) {
Text(
text = "Paired Bluetooth Devices:",
text = "Paired Bluetooth Devices:",
fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
if (pairedDevices.isEmpty()) {
if (pairedBluetoothDevices.isEmpty()) {
Text(
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
fontSize = 13.sp
)
} else {
pairedDevices.forEach { (name, address) ->
pairedBluetoothDevices.forEach { (name, address) ->
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectingFor.value == "tx") {
txAddress.value = address
txName.value = name
txName.value = name
if (isSingleRadio) {
rxAddress.value = address
rxName.value = name
}
} else {
rxAddress.value = address
rxName.value = name
rxName.value = name
}
selectingFor.value = ""
}
.padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(name, modifier = Modifier.weight(1f))
@@ -684,15 +679,26 @@ fun RadioControlDialog(
Spacer(modifier = Modifier.height(6.dp))
}
// Baud rate — FlowRow so all chips fit on narrow screens
Text("Baud Rate:", fontWeight = FontWeight.Medium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 6,
modifier = Modifier.fillMaxWidth()
) {
baudRates.forEach { rate ->
FilterChip(
selected = rate == baudRate.intValue,
onClick = { baudRate.intValue = rate },
label = { Text(rate.toString(), fontSize = 12.sp) },
enabled = enabled.value
onClick = { baudRate.intValue = rate },
label = {
Text(
text = baudLabel(rate),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
)
}
}
@@ -700,3 +706,20 @@ fun RadioControlDialog(
}
}
}
private fun compactRadioModelLabel(model: String): String = when (model) {
RadioControlSettings.MODEL_YAESU_FT817 -> "FT-817/818"
RadioControlSettings.MODEL_YAESU_FT857 -> "FT-857/897"
RadioControlSettings.MODEL_ICOM_IC705 -> "IC-705"
else -> model
}
private fun baudLabel(rate: Int): String = when (rate) {
4800 -> "4k"
9600 -> "9k"
19200 -> "19k"
38400 -> "38k"
57600 -> "57k"
115200 -> "115k"
else -> rate.toString()
}
@@ -25,11 +25,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
@@ -39,6 +40,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -191,6 +193,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (dialogs.radioControl) {
RadioControlDialog(
initialSettings = uiState.radioControlSettings,
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
@@ -219,7 +222,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
}
TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -241,7 +244,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
} else {
TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
TopCard(
onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
@@ -299,8 +302,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
if (isVerticalLayout) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OtherCard(uiState.otherSettings, onAction)
CardCredits()
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
) {
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
}
}
}
@@ -340,8 +357,6 @@ private fun LocationCard(
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°")
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Qth: ${settings.stationPos.qthLocator}")
}
Spacer(modifier = Modifier.height(1.dp))
@@ -481,41 +496,83 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values) {}
OtherCard(settings = values, onAction = {})
}
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
)
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
Spacer(modifier = Modifier.height(4.dp))
// Display preferences: UTC clock + night filter
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
}
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Radar behavior: sweep animation + sensor control
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Data management (full width)
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
Spacer(modifier = Modifier.height(4.dp))
// Compass calibration sliders at the bottom
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset,
value = settings.radarCompassOffset,
range = -180f..180f
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
Spacer(modifier = Modifier.height(4.dp))
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset_elev,
value = settings.radarCompassOffsetElev,
range = -90f..90f
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
Spacer(modifier = Modifier.height(4.dp))
}
}
}
@Composable
private fun CompassOffsetRow(
labelResId: Int,
value: Float,
range: ClosedFloatingPointRange<Float>,
onValueChange: (Float) -> Unit
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = labelResId))
Text(text = "${value.toInt()}°")
}
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = value,
onValueChange = onValueChange,
valueRange = range
)
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
@@ -528,6 +585,21 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
}
@Composable
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(id = labelResId),
modifier = Modifier.weight(1f)
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -560,11 +632,14 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
@Composable
private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier.fillMaxWidth()
modifier = modifier
.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxHeight()
) {
Text(
text = stringResource(id = R.string.prefs_outro_title),
@@ -573,8 +648,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
Text(
text = stringResource(id = R.string.prefs_outro_thanks)
)
// 感谢列表与保修声明之间保留约两行间距 (用户要求)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.prefs_outro_license),
color = MaterialTheme.colorScheme.primary
@@ -590,7 +663,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.heightIn(min = 48.dp)
.height(48.dp)
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
@@ -605,6 +678,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee()
)
}
}
@@ -41,7 +41,8 @@ data class SettingsState(
val otherSettings: OtherSettings,
val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings
val dataSourcesSettings: DataSourcesSettings,
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
)
sealed interface SettingsAction {
@@ -64,6 +65,8 @@ 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 SetRadarCompassOffset(val value: Float) : SettingsAction
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SettingsViewModel(
private val container: IMainContainer,
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast
@@ -47,7 +48,8 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
pairedBluetoothDevices = container.providePairedBluetoothDevices()
)
)
@@ -121,6 +123,8 @@ 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.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
@@ -204,6 +208,7 @@ class SettingsViewModel(
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SettingsViewModel(
container = container,
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast()
+17
View File
@@ -0,0 +1,17 @@
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
@@ -0,0 +1,370 @@
package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.PaddingValues
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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 androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.feature.status.R as StatusR
import java.util.Calendar
/**
* Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
*/
@Composable
private fun statusColorOf(statusText: String): Color {
return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiary // Active
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer // Telemetry
statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F) // NotHeard pink (no semantic slot)
else ->
MaterialTheme.colorScheme.error // Conflict
}
}
private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
@Composable
fun SatStatusScreen(container: IMainContainer) {
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.padding(horizontal = 8.dp)
) {
// Top: legend + refresh
StatusHeader(
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() }
)
LegendRow()
when {
uiState.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.error != null && uiState.statuses.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(id = R.string.amsat_load_failed),
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.refresh() }) {
Text(text = stringResource(id = R.string.amsat_retry))
}
}
}
else -> {
// Header
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
// Row
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(
status = status,
onClickDay = { day -> selectedDay = status to day }
)
}
}
}
}
}
selectedDay?.let { (status, day) ->
ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
onDismiss = { selectedDay = null }
)
}
}
/** Top: update time + refresh button (spinner while loading) */
@Composable
private fun StatusHeader(
fetchedAtUtcMs: Long,
isRefreshing: Boolean,
onRefresh: () -> Unit
) {
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
val angle by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
label = "angle"
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh),
contentAlignment = Alignment.Center
) {
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Image(
painter = painterResource(id = StatusR.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier
.size(18.dp)
.rotate(angle),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
}
}
}
/** Legend (official four colors) */
@Composable
private fun LegendRow() {
val legend = listOf(
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary,
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
legend.forEach { (label, color) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(2.dp))
.background(color)
)
Spacer(modifier = Modifier.width(3.dp))
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
/** Header: satellite name + 6 day dates */
@Composable
private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.amsat_name),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(2f).padding(start = 4.dp)
)
dates.forEach { date ->
Text(
text = date,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(0.8f)
)
}
}
}
/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = status.name,
fontSize = 11.sp,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(2f).padding(start = 4.dp)
)
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell(
slot = slot,
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
onClick = { onClickDay(day) }
)
}
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none */
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
Box(
modifier = modifier
.height(24.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(
text = slot.count.toString(),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
/** Report detail dialog (3 levels: callsign/date/time/grid) */
@Composable
private fun ReportDialog(
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }
.mapNotNull { reports[it] }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "$statusName · ${day.dateLabel}") },
text = {
if (dayReports.isEmpty()) {
Text(stringResource(id = R.string.amsat_no_reports))
} else {
LazyColumn(modifier = Modifier.height(320.dp)) {
items(dayReports) { report ->
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColorOf(report.statusText))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = report.statusText,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(thickness = 0.5.dp)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
}
)
}
private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance()
cal.timeInMillis = utcMs
return "%02d-%02d %02d:%02d".format(
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
}
@@ -0,0 +1,94 @@
package com.rtbishop.look4sat.feature.status
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SatStatusUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null
)
class SatStatusViewModel(
private val container: IMainContainer
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
fetch()
}
fun fetch() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val page = container.amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
}
}
} catch (exception: Exception) {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
}
}
}
}
fun refresh() {
if (_uiState.value.isRefreshing) return
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
val page = container.amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
} catch (exception: Exception) {
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
}
}
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container)
}
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
>
<path
android:fillColor="#FF000000"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
+1
View File
@@ -2,4 +2,5 @@
org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.tooling.parallel=true
org.gradle.workers.max=4
+3 -8
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "448"
appVersionCode = "445"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8"
appVersionName = "4.4.5"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -11,11 +11,6 @@ minSdk = "24"
jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
# Fork application ID: official app uses com.rtbishop.look4sat with a different
# signing cert. Installing a fork with the same applicationId would conflict
# with the official build (signature mismatch / cannot overwrite), so the fork
# ships under its own applicationId suffix.
applicationId = "com.rtbishop.look4sat.bg7nta"
android-gradle-plugin = "9.3.1"
@@ -28,7 +23,7 @@ compose-activity = "1.13.0"
compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.5"
google-ksp = "2.3.8"
google-ksp = "2.3.10"
kotlin = "2.4.10"
kotlin-coroutines = "1.11.0"
+2 -3
View File
@@ -22,10 +22,9 @@ include(
)
include(
":feature:map",
":feature:mutual",
":feature:passes",
":feature:radar",
":feature:roaming",
":feature:satellites",
":feature:settings"
":feature:settings",
":feature:status"
)