mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf23d53ee8 |
No files matched your search
@@ -4,7 +4,3 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
- package-ecosystem: "bundler"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "never"
|
|
||||||
@@ -64,4 +64,3 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
.hermes/
|
|
||||||
@@ -8,15 +8,16 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||||
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||||
no tracking, no network required after initial data download.
|
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||||
|
data download.
|
||||||
|
|
||||||
## Architecture & Design
|
## Architecture
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
**Clean Architecture layers:**
|
||||||
@@ -33,11 +34,9 @@ no tracking, no network required after initial data download.
|
|||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
| `feature:settings` | User preferences |
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
**Feature isolation:**
|
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||||
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
|
||||||
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
|
||||||
|
|
||||||
## Build & Platform
|
## Build & Run
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Debug build
|
# Debug build
|
||||||
@@ -51,55 +50,54 @@ no tracking, no network required after initial data download.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||||
|
|
||||||
## Tech Stack
|
## Key Libraries
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||||
- **OkHttp** 5.x for data downloads
|
- **OkHttp** 5.x for TLE downloads
|
||||||
- **OSMDroid** for map rendering
|
- **OSMDroid** for map rendering
|
||||||
- **Kotlin Serialization** for navigation args and parsing
|
- **Kotlin Serialization** for navigation args and data parsing
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
## Data Formats & Migration
|
## Data Formats & Migration
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
**TLE vs. OMM/CSV format:**
|
||||||
|
|
||||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||||
- **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)
|
|
||||||
|
|
||||||
## Engineering Heuristics (Lazy = Efficient)
|
- **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.
|
||||||
|
|
||||||
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
**Current implementation:**
|
||||||
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||||
1. Does this need to be built now? (YAGNI)
|
- TLE data is downloaded from configured sources and stored in Room database
|
||||||
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||||
3. Does Kotlin/Java stdlib already solve it?
|
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||||
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?"
|
|
||||||
|
|
||||||
## Bug-Fix Policy
|
**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.
|
||||||
|
|
||||||
- Fix root cause, not just the reported symptom.
|
## Code Style
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -107,9 +105,12 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
- 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.
|
||||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
- `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.
|
||||||
|
|
||||||
## Copilot Working Mode: Code-Only
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ It is now and always will be completely ad-free and open-source.
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-keep class org.osmdroid.** { *; }
|
||||||
|
-keep class com.rtbishop.look4sat.feature.map.*TiandituTileSource* { *; }
|
||||||
|
-dontwarn org.osmdroid.**
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name="cn.bh2vsq.look4sat.MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name="cn.bh2vsq.look4sat.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat" />
|
android:value="cn.bh2vsq.look4sat" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
+2
-2
@@ -15,7 +15,7 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package cn.bh2vsq.look4sat
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package cn.bh2vsq.look4sat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
* 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 cn.bh2vsq.look4sat
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
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.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
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.compose.collectAsStateWithLifecycle
|
||||||
|
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.presentation.DeeplinkResolver
|
||||||
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
|
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.passes.PassesDestination
|
||||||
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NavRoot(deeplink: String? = null) {
|
||||||
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
LaunchedEffect(deeplink) {
|
||||||
|
deeplink?.let {
|
||||||
|
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||||
|
rootBackStack.add(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
|
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||||
|
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||||
|
NavDisplay(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
backStack = rootBackStack,
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { slideInTransition },
|
||||||
|
popTransitionSpec = { slideOutTransition },
|
||||||
|
predictivePopTransitionSpec = { slideOutTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
innerPadding.calculateTopPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
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.Map, Screen.Settings)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
NavigationSuiteScaffold(
|
||||||
|
navigationSuiteItems = {
|
||||||
|
navItems.forEach { screen ->
|
||||||
|
val isSelected = when (currentKey) {
|
||||||
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
|
is Screen.Map -> screen is Screen.Map
|
||||||
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
item(
|
||||||
|
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||||
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected) return@item
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
),
|
||||||
|
layoutType = when {
|
||||||
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
NavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { fadeTransition },
|
||||||
|
popTransitionSpec = { fadeTransition },
|
||||||
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
// Required for saving Compose state per entry
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
// Required for ViewModel scoping per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
// navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry<Screen.Radar> {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,270 +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
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
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.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
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.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
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.compose.collectAsStateWithLifecycle
|
|
||||||
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.RadioTrackingState
|
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
|
||||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
|
||||||
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.passes.PassesDestination
|
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
|
||||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NavRoot(deeplink: String? = null) {
|
|
||||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
|
||||||
val deeplinkResolver = DeeplinkResolver()
|
|
||||||
LaunchedEffect(deeplink) {
|
|
||||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
|
||||||
}
|
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
|
||||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
|
||||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
|
||||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
|
||||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
|
||||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
|
||||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
|
||||||
slideOutHorizontally(tween(300)) { it }
|
|
||||||
NavDisplay(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
backStack = rootBackStack,
|
|
||||||
onBack = navigateBack,
|
|
||||||
transitionSpec = { pushTransition },
|
|
||||||
popTransitionSpec = { popTransition },
|
|
||||||
predictivePopTransitionSpec = { popTransition },
|
|
||||||
entryDecorators = listOf(
|
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
|
||||||
entry<RadarDestination> {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background
|
|
||||||
) {
|
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|
||||||
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()
|
|
||||||
|
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
|
||||||
val currentKey = backStack.lastOrNull()
|
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
|
||||||
fadeOut(animationSpec = tween(300))
|
|
||||||
val navItems =
|
|
||||||
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
|
||||||
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
|
||||||
low = otherSettings.lowElevation,
|
|
||||||
high = otherSettings.highElevation
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
NavigationSuiteScaffold(
|
|
||||||
navigationSuiteItems = {
|
|
||||||
navItems.forEach { screen ->
|
|
||||||
val isSelected = when (currentKey) {
|
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
|
||||||
is Screen.Status -> screen is Screen.Status
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
item(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(screen.iconResId),
|
|
||||||
contentDescription = stringResource(screen.titleResId)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = {
|
|
||||||
if (isSelected) return@item
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
),
|
|
||||||
layoutType = when {
|
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
NavDisplay(
|
|
||||||
backStack = backStack,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
onBack = navigateBack,
|
|
||||||
transitionSpec = { fadeTransition },
|
|
||||||
popTransitionSpec = { fadeTransition },
|
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
|
||||||
entryDecorators = listOf(
|
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Satellites> {
|
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
entry<Screen.Passes> {
|
|
||||||
PassesDestination { catNum, aosTime ->
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
|
||||||
navigateToRadar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry<Screen.Status> { SatStatusDestination() }
|
|
||||||
entry<Screen.Map> { MapDestination() }
|
|
||||||
entry<Screen.Settings> { SettingsDestination() }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (trackingState.isActive) {
|
|
||||||
TrackingBanner(
|
|
||||||
state = trackingState,
|
|
||||||
onClick = {
|
|
||||||
val pass = trackingState.currentPass
|
|
||||||
if (pass != null) {
|
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
|
||||||
navigateToRadar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
|
||||||
val alpha by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 1f,
|
|
||||||
targetValue = 0.4f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulseAlpha"
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Tracking: ${state.currentPass?.name ?: ""}",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
val connections = listOfNotNull(
|
|
||||||
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = connections.joinToString("/"),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<base-config cleartextTrafficPermitted="true" />
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">tianditu.gov.cn</domain>
|
||||||
|
</domain-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
-1
@@ -36,7 +36,6 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
implementation(project(":feature:status"))
|
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
implementation(libs.compose.navigation3)
|
implementation(libs.compose.navigation3)
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
|
|||||||
@@ -45,12 +45,7 @@ interface Look4SatDao {
|
|||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||||
"""
|
|
||||||
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
|
||||||
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
|
|||||||
@@ -1,364 +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.data.framework
|
|
||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.BluetoothSocket
|
|
||||||
import android.util.Log
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Icom IC-705 CI-V controller over Bluetooth SPP.
|
|
||||||
*
|
|
||||||
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
|
|
||||||
* level, …). A reply to any command we send may therefore be buried in
|
|
||||||
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
|
|
||||||
* entire accumulated buffer for the frame we expect rather than assuming
|
|
||||||
* the very next byte is the response.
|
|
||||||
*/
|
|
||||||
class Ic705Controller(
|
|
||||||
private val bluetoothManager: BluetoothManager,
|
|
||||||
private val deviceAddress: String
|
|
||||||
) : IRadioController {
|
|
||||||
|
|
||||||
private val tag = "IC705"
|
|
||||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
|
||||||
private val ioMutex = Mutex()
|
|
||||||
|
|
||||||
/** Time budget (ms) to wait for a response amid broadcast noise. */
|
|
||||||
private val ACK_TIMEOUT_MS = 500L
|
|
||||||
/** Polling interval while draining the input buffer. */
|
|
||||||
private val POLL_INTERVAL_MS = 20L
|
|
||||||
/** Small pause after writing a command before reading the response. */
|
|
||||||
private val WRITE_SETTLE_MS = 50L
|
|
||||||
|
|
||||||
private var socket: BluetoothSocket? = null
|
|
||||||
private var outputStream: OutputStream? = null
|
|
||||||
private var inputStream: InputStream? = null
|
|
||||||
|
|
||||||
override var isConnected: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
// ── Connection ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
if (isConnected) return@withContext true
|
|
||||||
if (deviceAddress.isBlank()) return@withContext false
|
|
||||||
try {
|
|
||||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
|
||||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
|
||||||
btSocket.connect()
|
|
||||||
socket = btSocket
|
|
||||||
outputStream = btSocket.outputStream
|
|
||||||
inputStream = btSocket.inputStream
|
|
||||||
isConnected = true
|
|
||||||
// Enter VFO mode — frequency/mode commands return FA if the radio
|
|
||||||
// is in memory-channel mode. Safe to send regardless of current state.
|
|
||||||
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
|
|
||||||
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
|
|
||||||
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "Connect error: ${e.message}")
|
|
||||||
isConnected = false
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun disconnect() {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
inputStream?.close()
|
|
||||||
outputStream?.close()
|
|
||||||
socket?.close()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "Disconnect error: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
inputStream = null
|
|
||||||
outputStream = null
|
|
||||||
socket = null
|
|
||||||
isConnected = false
|
|
||||||
Log.i(tag, "Disconnected from $deviceAddress")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IRadioController – standard operations ──────────────────────────────
|
|
||||||
|
|
||||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
|
|
||||||
ioMutex.withLock {
|
|
||||||
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
|
|
||||||
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
sendAndWaitAck(cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
|
|
||||||
Log.w(tag, "setMode: unknown mode '$mode'")
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
Log.d(tag, "setMode: $mode")
|
|
||||||
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
Log.d(tag, "setCtcssMode: $enabled")
|
|
||||||
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
|
|
||||||
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
|
|
||||||
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
|
|
||||||
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
|
||||||
ioMutex.withLock {
|
|
||||||
val cmd = IcomCivProtocol.buildReadFreqCommand()
|
|
||||||
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
|
|
||||||
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
|
|
||||||
IcomCivProtocol.parseFreqModePayload(payload).also {
|
|
||||||
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
|
|
||||||
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
Log.w(tag, "pttOn: not used for IC-705")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
Log.w(tag, "pttOff: not used for IC-705")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IRadioController – IC-705 extended operations ───────────────────────
|
|
||||||
|
|
||||||
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
|
|
||||||
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
|
|
||||||
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
|
|
||||||
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
|
|
||||||
else IcomCivProtocol.buildSelectVfoBCommand()
|
|
||||||
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
|
|
||||||
*/
|
|
||||||
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
|
|
||||||
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
|
|
||||||
* In split mode the radio automatically switches active VFO on PTT, so
|
|
||||||
* always writing to the active VFO is the correct strategy.
|
|
||||||
*/
|
|
||||||
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
|
|
||||||
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
|
|
||||||
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
|
|
||||||
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
|
|
||||||
*/
|
|
||||||
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
|
|
||||||
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
|
|
||||||
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
|
|
||||||
* Used for tuning detection in split mode.
|
|
||||||
*/
|
|
||||||
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
|
|
||||||
ioMutex.withLock {
|
|
||||||
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
|
|
||||||
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
|
||||||
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
|
||||||
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
|
||||||
if (payload.size < 6) {
|
|
||||||
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
val freqBcd = payload.sliceArray(1..5)
|
|
||||||
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
|
||||||
Log.d(tag, "readWorkingFreq: ${freq}Hz")
|
|
||||||
freq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
|
|
||||||
* Used for tuning detection in split mode.
|
|
||||||
*/
|
|
||||||
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
|
|
||||||
ioMutex.withLock {
|
|
||||||
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
|
|
||||||
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
|
||||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
|
||||||
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
|
||||||
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
|
||||||
if (payload.size < 6) {
|
|
||||||
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
val freqBcd = payload.sliceArray(1..5)
|
|
||||||
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
|
||||||
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
|
|
||||||
freq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal I/O helpers ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write [cmd] to the radio and drain the input stream for up to
|
|
||||||
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
|
|
||||||
*/
|
|
||||||
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
|
|
||||||
if (!write(cmd)) return false
|
|
||||||
delay(WRITE_SETTLE_MS)
|
|
||||||
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
|
||||||
val ok = IcomCivProtocol.containsAck(buf)
|
|
||||||
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write [cmd] to the radio and drain the input stream for up to
|
|
||||||
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
|
|
||||||
* Returns the payload bytes of that frame, or null on timeout/error.
|
|
||||||
*/
|
|
||||||
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
|
|
||||||
if (!write(cmd)) return null
|
|
||||||
delay(WRITE_SETTLE_MS)
|
|
||||||
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
|
||||||
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
|
|
||||||
if (response == null) {
|
|
||||||
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
|
|
||||||
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
|
||||||
}
|
|
||||||
return response?.payload
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
|
|
||||||
* Exits early as soon as a complete CI-V frame addressed to us is present
|
|
||||||
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
|
|
||||||
* timeout on responses that already arrived.
|
|
||||||
*/
|
|
||||||
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
|
|
||||||
val result = mutableListOf<Byte>()
|
|
||||||
val deadline = System.currentTimeMillis() + timeoutMs
|
|
||||||
val stream = inputStream ?: return ByteArray(0)
|
|
||||||
while (System.currentTimeMillis() < deadline) {
|
|
||||||
try {
|
|
||||||
val available = stream.available()
|
|
||||||
if (available > 0) {
|
|
||||||
val chunk = ByteArray(available)
|
|
||||||
val read = stream.read(chunk)
|
|
||||||
if (read > 0) {
|
|
||||||
result.addAll(chunk.take(read))
|
|
||||||
// Exit early once we have a complete frame for us
|
|
||||||
if (hasCompleteFrameForUs(result)) break
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
delay(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "Drain error: ${e.message}")
|
|
||||||
isConnected = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if [buf] contains a complete CI-V frame addressed to the
|
|
||||||
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
|
|
||||||
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
|
|
||||||
* always the frame terminator.
|
|
||||||
*/
|
|
||||||
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
|
|
||||||
var i = 0
|
|
||||||
while (i < buf.size - 4) {
|
|
||||||
if (buf[i] == IcomCivProtocol.PREAMBLE &&
|
|
||||||
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
|
|
||||||
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
|
|
||||||
buf[i + 3] == IcomCivProtocol.ADDR_IC705
|
|
||||||
) {
|
|
||||||
for (k in i + 4 until buf.size) {
|
|
||||||
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
|
|
||||||
}
|
|
||||||
return false // header found but no FD yet
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun write(bytes: ByteArray): Boolean {
|
|
||||||
return try {
|
|
||||||
outputStream?.write(bytes)
|
|
||||||
outputStream?.flush()
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "Write error: ${e.message}")
|
|
||||||
isConnected = false
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,352 +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.data.framework
|
|
||||||
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Icom CI-V protocol encoder/decoder for the IC-705.
|
|
||||||
*
|
|
||||||
* Frame structure:
|
|
||||||
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
|
|
||||||
*
|
|
||||||
* IC-705 default CI-V address : 0xA4
|
|
||||||
* Controller (us) address : 0xE0
|
|
||||||
*/
|
|
||||||
object IcomCivProtocol {
|
|
||||||
|
|
||||||
// ── Framing constants ──────────────────────────────────────────────────
|
|
||||||
const val PREAMBLE: Byte = 0xFE.toByte()
|
|
||||||
const val END_OF_MSG: Byte = 0xFD.toByte()
|
|
||||||
const val ACK_OK: Byte = 0xFB.toByte()
|
|
||||||
const val ACK_NG: Byte = 0xFA.toByte()
|
|
||||||
|
|
||||||
// ── Address constants ──────────────────────────────────────────────────
|
|
||||||
/** Default CI-V address of the IC-705. */
|
|
||||||
const val ADDR_IC705: Byte = 0xA4.toByte()
|
|
||||||
/** Default CI-V address of the controller (us). */
|
|
||||||
const val ADDR_CTRL: Byte = 0xE0.toByte()
|
|
||||||
|
|
||||||
// ── Command bytes ──────────────────────────────────────────────────────
|
|
||||||
/** Read operating frequency (main VFO). */
|
|
||||||
const val CMD_READ_FREQ: Byte = 0x03
|
|
||||||
/** Set operating frequency (main VFO). */
|
|
||||||
const val CMD_SET_FREQ: Byte = 0x05
|
|
||||||
/** Set operating mode. */
|
|
||||||
const val CMD_SET_MODE: Byte = 0x06
|
|
||||||
/** Select VFO / memory. */
|
|
||||||
const val CMD_SELECT_VFO: Byte = 0x07
|
|
||||||
/**
|
|
||||||
* Select operating mode (VFO vs memory-channel).
|
|
||||||
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
|
|
||||||
* memory-channel mode — frequency/mode commands return FA until it is.
|
|
||||||
*/
|
|
||||||
const val CMD_SELECT_OP_MODE: Byte = 0x08
|
|
||||||
/** Set repeater duplex / SPLIT. */
|
|
||||||
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
|
|
||||||
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
|
|
||||||
const val CMD_BAND_SELECT: Byte = 0x1A
|
|
||||||
/** Read/write CTCSS tone frequency. */
|
|
||||||
const val CMD_CTCSS_TONE: Byte = 0x1B
|
|
||||||
/** Read/write misc settings (used for enabling CTCSS encode). */
|
|
||||||
const val CMD_MISC_SETTING: Byte = 0x16
|
|
||||||
/** Read/write selected-VFO frequency (cmd 0x25). */
|
|
||||||
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
|
|
||||||
|
|
||||||
// ── Sub-command bytes ──────────────────────────────────────────────────
|
|
||||||
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
|
|
||||||
const val SUB_VFO_A: Byte = 0x00
|
|
||||||
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
|
|
||||||
const val SUB_VFO_B: Byte = 0x01
|
|
||||||
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
|
|
||||||
const val SUB_SPLIT_OFF: Byte = 0x00
|
|
||||||
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
|
|
||||||
const val SUB_SPLIT_ON: Byte = 0x01
|
|
||||||
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
|
|
||||||
const val SUB_SELECTED_VFO: Byte = 0x00
|
|
||||||
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
|
|
||||||
const val SUB_UNSELECTED_VFO: Byte = 0x01
|
|
||||||
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
|
|
||||||
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
|
|
||||||
|
|
||||||
// ── Mode bytes ────────────────────────────────────────────────────────
|
|
||||||
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
|
|
||||||
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
|
||||||
"LSB" to 0x00,
|
|
||||||
"USB" to 0x01,
|
|
||||||
"AM" to 0x02,
|
|
||||||
"CW" to 0x03,
|
|
||||||
"RTTY" to 0x04,
|
|
||||||
"FM" to 0x05,
|
|
||||||
"WFM" to 0x06,
|
|
||||||
"CW-R" to 0x07,
|
|
||||||
"RTTY-R" to 0x08,
|
|
||||||
"DV" to 0x12,
|
|
||||||
"AFSK" to 0x05 // AFSK uses FM modulation
|
|
||||||
)
|
|
||||||
|
|
||||||
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
|
||||||
|
|
||||||
// ── Frequency BCD encoding ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
|
|
||||||
*
|
|
||||||
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
|
|
||||||
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
|
|
||||||
* [00, 00, 50, 45, 01]
|
|
||||||
*/
|
|
||||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
|
||||||
val digits = String.format(Locale.US, "%010d", frequencyHz)
|
|
||||||
val bcd = ByteArray(5)
|
|
||||||
for (i in 0 until 5) {
|
|
||||||
// digits are MSB first; we want pair index 0 = LSB pair
|
|
||||||
val pairIndex = 4 - i
|
|
||||||
val high = digits[pairIndex * 2] - '0'
|
|
||||||
val low = digits[pairIndex * 2 + 1] - '0'
|
|
||||||
bcd[i] = ((high shl 4) or low).toByte()
|
|
||||||
}
|
|
||||||
return bcd
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
|
|
||||||
*/
|
|
||||||
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
|
||||||
// Build digit string MSB→LSB by reversing the byte order
|
|
||||||
var freqHz = 0L
|
|
||||||
for (i in 4 downTo 0) {
|
|
||||||
val b = bcd[i].toInt() and 0xFF
|
|
||||||
val high = b shr 4
|
|
||||||
val low = b and 0x0F
|
|
||||||
freqHz = freqHz * 100 + high * 10 + low
|
|
||||||
}
|
|
||||||
return freqHz
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
|
|
||||||
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
|
|
||||||
*/
|
|
||||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
|
||||||
val tone01 = (toneHz * 10).toLong()
|
|
||||||
val digits = String.format(Locale.US, "%04d", tone01)
|
|
||||||
return byteArrayOf(
|
|
||||||
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
|
|
||||||
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Message builders ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
|
|
||||||
private fun frame(vararg payload: Byte): ByteArray {
|
|
||||||
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
|
|
||||||
payload +
|
|
||||||
byteArrayOf(END_OF_MSG)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set operating frequency via CMD 0x05 (main VFO). */
|
|
||||||
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
|
||||||
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
|
|
||||||
* This updates whichever VFO is currently active (RX or TX after split).
|
|
||||||
*/
|
|
||||||
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
|
|
||||||
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
|
|
||||||
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
|
|
||||||
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
|
|
||||||
* Use this to update the TX VFO when PTT is on.
|
|
||||||
*/
|
|
||||||
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
|
|
||||||
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read operating frequency (CMD 0x03). */
|
|
||||||
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
|
|
||||||
|
|
||||||
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
|
|
||||||
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
|
|
||||||
|
|
||||||
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
|
|
||||||
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select band via CMD 0x1A sub 0x00.
|
|
||||||
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
|
|
||||||
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
|
|
||||||
*/
|
|
||||||
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
|
|
||||||
val code = bandCodeForFrequency(frequencyHz) ?: return null
|
|
||||||
return frame(CMD_BAND_SELECT, 0x00, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a frequency in Hz to the IC-705 band stacking register code.
|
|
||||||
* Codes are BCD (band number in decimal expressed as hex nibbles).
|
|
||||||
*/
|
|
||||||
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
|
|
||||||
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
|
|
||||||
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
|
|
||||||
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
|
|
||||||
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
|
|
||||||
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
|
|
||||||
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
|
|
||||||
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
|
|
||||||
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
|
|
||||||
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
|
|
||||||
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
|
|
||||||
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
|
|
||||||
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
|
|
||||||
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
|
|
||||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
|
||||||
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
|
||||||
return frame(CMD_SET_MODE, modeByte)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Select VFO-A (CMD 0x07 sub 0x00). */
|
|
||||||
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
|
|
||||||
|
|
||||||
/** Select VFO-B (CMD 0x07 sub 0x01). */
|
|
||||||
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter VFO operating mode (CMD 0x08 sub 0x00).
|
|
||||||
* Sent after connect — if the radio is in memory-channel mode frequency
|
|
||||||
* and mode commands return FA until this is issued.
|
|
||||||
*/
|
|
||||||
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
|
|
||||||
|
|
||||||
/** Enable or disable SPLIT mode (CMD 0x0F). */
|
|
||||||
fun buildSplitModeCommand(enable: Boolean): ByteArray {
|
|
||||||
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
|
|
||||||
return frame(CMD_DUPLEX_SPLIT, sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
|
|
||||||
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
|
|
||||||
*/
|
|
||||||
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
|
||||||
val value: Byte = if (enabled) 0x01 else 0x00
|
|
||||||
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
|
|
||||||
*/
|
|
||||||
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
|
||||||
val bcd = encodeCtcssToneBcd(toneHz)
|
|
||||||
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Response parsing ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find and parse a complete CI-V response frame from a buffer.
|
|
||||||
*
|
|
||||||
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
|
|
||||||
* complete frame was found. The search is tolerant of interleaved
|
|
||||||
* broadcast traffic.
|
|
||||||
*
|
|
||||||
* @param buf bytes accumulated from the radio
|
|
||||||
* @param expectCmd the command byte we are looking for in the reply, or
|
|
||||||
* null to accept any command response from the radio
|
|
||||||
*/
|
|
||||||
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
|
|
||||||
var i = 0
|
|
||||||
while (i < buf.size - 5) {
|
|
||||||
// Look for FE FE preamble
|
|
||||||
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
|
||||||
val dest = buf[i + 2]
|
|
||||||
val src = buf[i + 3]
|
|
||||||
val cmd = buf[i + 4]
|
|
||||||
// We only care about frames addressed to us from the radio
|
|
||||||
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
|
||||||
// Find the terminating FD
|
|
||||||
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
|
||||||
if (fdIdx < 0) break // incomplete frame, wait for more data
|
|
||||||
val payload = buf.copyOfRange(i + 5, fdIdx)
|
|
||||||
if (expectCmd == null || cmd == expectCmd) {
|
|
||||||
return ParsedResponse(cmd, payload, fdIdx + 1)
|
|
||||||
}
|
|
||||||
i = fdIdx + 1
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
|
|
||||||
for (k in startIndex until size) if (this[k] == b) return k
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a buffer contains an OK acknowledgement (FB FD) from
|
|
||||||
* the radio. Tolerates broadcast noise before the ACK.
|
|
||||||
*/
|
|
||||||
fun containsAck(buf: ByteArray): Boolean {
|
|
||||||
var i = 0
|
|
||||||
while (i < buf.size - 5) {
|
|
||||||
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
|
||||||
val dest = buf[i + 2]
|
|
||||||
val src = buf[i + 3]
|
|
||||||
val cmd = buf[i + 4]
|
|
||||||
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
|
||||||
// Skip to FD
|
|
||||||
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
|
||||||
if (fdIdx < 0) break
|
|
||||||
if (cmd == ACK_OK) return true
|
|
||||||
if (cmd == ACK_NG) return false
|
|
||||||
i = fdIdx + 1
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
|
||||||
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
|
||||||
*/
|
|
||||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
|
||||||
if (payload.size < 6) return null
|
|
||||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
|
||||||
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
|
||||||
return freqHz to mode
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hex dump of bytes, useful for debug logging. */
|
|
||||||
fun toHex(bytes: ByteArray): String =
|
|
||||||
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
|
|
||||||
|
|
||||||
data class ParsedResponse(
|
|
||||||
val cmd: Byte,
|
|
||||||
val payload: ByteArray,
|
|
||||||
/** Index in the source buffer immediately after the FD terminator. */
|
|
||||||
val nextOffset: Int
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+26
-65
@@ -17,10 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -33,30 +31,18 @@ class NetworkReporter(
|
|||||||
private val rotatorServer: String,
|
private val rotatorServer: String,
|
||||||
private val rotatorPort: Int,
|
private val rotatorPort: Int,
|
||||||
private val frequencyServer: String,
|
private val frequencyServer: String,
|
||||||
private val frequencyPort: Int,
|
private val frequencyPort: Int
|
||||||
private val frequencyOffsetHz: Long = 0L
|
|
||||||
) : IReporter {
|
) : IReporter {
|
||||||
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
private val connectionMutex = Mutex()
|
|
||||||
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
|
||||||
|
|
||||||
private var rotatorSocket: SocketChannel? = null
|
private var rotatorSocket: SocketChannel? = null
|
||||||
private var rotatorConnected = false
|
private var rotatorConnected = false
|
||||||
|
private var rotatorConnecting = false
|
||||||
|
|
||||||
private var frequencySocket: SocketChannel? = null
|
private var frequencySocket: SocketChannel? = null
|
||||||
private var frequencyConnected = false
|
private var frequencyConnected = false
|
||||||
|
private var frequencyConnecting = false
|
||||||
init {
|
|
||||||
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
|
||||||
reporterScope.launch {
|
|
||||||
for (command in frequencyCommands) {
|
|
||||||
ensureFrequencyConnected()
|
|
||||||
if (!frequencyConnected) continue
|
|
||||||
write(frequencySocket, command) { resetFrequencyConnection() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
@@ -72,43 +58,46 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long) {
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
val clampedOffset = frequencyOffsetHz.coerceIn(
|
reporterScope.launch {
|
||||||
Constants.FREQ_OFFSET_MIN_HZ,
|
ensureFrequencyConnected()
|
||||||
Constants.FREQ_OFFSET_MAX_HZ
|
if (!frequencyConnected) return@launch
|
||||||
)
|
val command = format
|
||||||
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
.replace($$"$FREQ", frequency.toString())
|
||||||
val command = format
|
.unescapeControlChars()
|
||||||
.replace($$"$FREQ", correctedFreq.toString())
|
write(frequencySocket, command) { frequencyConnected = false }
|
||||||
.unescapeControlChars()
|
}
|
||||||
frequencyCommands.trySend(command)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureRotatorConnected() {
|
private fun ensureRotatorConnected() {
|
||||||
connectionMutex.withLock {
|
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||||
if (rotatorConnected || rotatorServer.isBlank()) return
|
reporterScope.launch {
|
||||||
try {
|
try {
|
||||||
resetRotatorConnection()
|
rotatorConnecting = true
|
||||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
rotatorConnected = true
|
rotatorConnected = true
|
||||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter rotator connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
resetRotatorConnection()
|
rotatorConnected = false
|
||||||
|
} finally {
|
||||||
|
rotatorConnecting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureFrequencyConnected() {
|
private fun ensureFrequencyConnected() {
|
||||||
connectionMutex.withLock {
|
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||||
if (frequencyConnected || frequencyServer.isBlank()) return
|
reporterScope.launch {
|
||||||
try {
|
try {
|
||||||
resetFrequencyConnection()
|
frequencyConnecting = true
|
||||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
frequencyConnected = true
|
frequencyConnected = true
|
||||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter frequency connect error: ${e.message}")
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
resetFrequencyConnection()
|
frequencyConnected = false
|
||||||
|
} finally {
|
||||||
|
frequencyConnecting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,9 +106,7 @@ class NetworkReporter(
|
|||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
while (buffer.hasRemaining()) {
|
socket?.write(buffer)
|
||||||
socket?.write(buffer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
println("NetworkReporter write error: ${e.message}")
|
||||||
@@ -127,32 +114,6 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetRotatorConnection() {
|
|
||||||
rotatorConnected = false
|
|
||||||
closeQuietly(rotatorSocket)
|
|
||||||
rotatorSocket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resetFrequencyConnection() {
|
|
||||||
frequencyConnected = false
|
|
||||||
closeQuietly(frequencySocket)
|
|
||||||
frequencySocket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun closeQuietly(socket: SocketChannel?) {
|
|
||||||
try {
|
|
||||||
socket?.close()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Long.safeAdd(delta: Long): Long {
|
|
||||||
return when {
|
|
||||||
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
|
||||||
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
|
||||||
else -> this + delta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.unescapeControlChars(): String =
|
private fun String.unescapeControlChars(): String =
|
||||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
}
|
}
|
||||||
+190
-389
@@ -19,10 +19,8 @@ package com.rtbishop.look4sat.core.data.framework
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
@@ -31,7 +29,6 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -47,8 +44,6 @@ class RadioTrackingService(
|
|||||||
) : IRadioTrackingService {
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
private val tag = "RadioTracking"
|
private val tag = "RadioTracking"
|
||||||
/** Delay between each step of the split-mode init sequence (ms). */
|
|
||||||
private val INIT_STEP_DELAY_MS = 200L
|
|
||||||
private val _state = MutableStateFlow(RadioTrackingState())
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
override val state: StateFlow<RadioTrackingState> = _state
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
@@ -56,427 +51,227 @@ class RadioTrackingService(
|
|||||||
private var rxController: IRadioController? = null
|
private var rxController: IRadioController? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
// ── Connection ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override suspend fun connectRadios() {
|
override suspend fun connectRadios() {
|
||||||
|
// Disconnect old controllers if any
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
|
|
||||||
|
// Read current addresses from settings
|
||||||
val rcSettings = settingsRepo.radioControlSettings.value
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
val txAddr = rcSettings.txRadioAddress
|
val txAddr = rcSettings.txRadioAddress
|
||||||
val rxAddr = rcSettings.rxRadioAddress
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
|
||||||
val isSplit = isIcom && rcSettings.splitMode
|
|
||||||
|
|
||||||
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
if (isSplit) {
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
// Single-radio split mode: only TX slot is used
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
if (txAddr.isBlank()) {
|
return
|
||||||
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
}
|
||||||
return
|
|
||||||
}
|
val tx = Ft817Controller(bluetoothManager, txAddr)
|
||||||
val tx = makeController(isIcom, txAddr)
|
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
||||||
txController = tx
|
txController = tx
|
||||||
rxController = null
|
rxController = rx
|
||||||
_state.update { it.copy(errorMessage = null) }
|
|
||||||
val txOk = tx.connect()
|
_state.update { it.copy(errorMessage = null) }
|
||||||
_state.update {
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
it.copy(
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
txConnected = txOk,
|
_state.update {
|
||||||
rxConnected = false,
|
it.copy(
|
||||||
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
|
txConnected = txOk,
|
||||||
)
|
rxConnected = rxOk,
|
||||||
}
|
errorMessage = when {
|
||||||
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
|
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
||||||
} else {
|
!txOk -> "Could not connect to TX radio ($txAddr)"
|
||||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
else -> null
|
||||||
return
|
}
|
||||||
}
|
)
|
||||||
val tx = makeController(isIcom, txAddr)
|
|
||||||
val rx = makeController(isIcom, rxAddr)
|
|
||||||
txController = tx
|
|
||||||
rxController = rx
|
|
||||||
_state.update { it.copy(errorMessage = null) }
|
|
||||||
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
|
||||||
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
txConnected = txOk,
|
|
||||||
rxConnected = rxOk,
|
|
||||||
errorMessage = when {
|
|
||||||
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
|
||||||
!txOk -> "Could not connect to TX radio ($txAddr)"
|
|
||||||
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeController(isIcom: Boolean, address: String): IRadioController =
|
|
||||||
if (isIcom) Ic705Controller(bluetoothManager, address)
|
|
||||||
else Ft817Controller(bluetoothManager, address)
|
|
||||||
|
|
||||||
override suspend fun disconnectRadios() {
|
override suspend fun disconnectRadios() {
|
||||||
stopTracking()
|
stopTracking()
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
txController = null
|
txController = null
|
||||||
rxController = null
|
rxController = null
|
||||||
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = false,
|
||||||
|
rxConnected = false,
|
||||||
|
isActive = false
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tracking ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isActive = true,
|
isActive = true,
|
||||||
currentPass = pass,
|
currentPass = pass,
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txBaseFreqHz
|
txBaseFrequencyHz = txBaseFreqHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
|
trackingJob = appScope.launch {
|
||||||
val rcSettings = settingsRepo.radioControlSettings.value
|
// Set modes on both radios at tracking start
|
||||||
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
val tx = txController
|
||||||
val isSplit = isIcom && rcSettings.splitMode
|
val rx = rxController
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
if (isSplit) {
|
val rxMode = transponder.downlinkMode
|
||||||
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
|
?: transponder.uplinkMode?.let {
|
||||||
} else {
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
|
}
|
||||||
}
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
}
|
tx.setMode(txMode)
|
||||||
|
Log.i(tag, "TX mode set to $txMode")
|
||||||
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
|
|
||||||
|
|
||||||
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
|
||||||
val tx = txController
|
|
||||||
val rx = rxController
|
|
||||||
|
|
||||||
// Initial setup: set band/mode/CTCSS on both radios
|
|
||||||
val txMode = transponder.uplinkMode
|
|
||||||
val rxMode = transponder.downlinkMode
|
|
||||||
?: transponder.uplinkMode?.let {
|
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
|
||||||
}
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
|
rx.setMode(rxMode)
|
||||||
|
Log.i(tag, "RX mode set to $rxMode")
|
||||||
if (tx != null && tx.isConnected && txMode != null) {
|
|
||||||
Log.d(tag, "Setting TX mode: $txMode")
|
|
||||||
tx.setMode(txMode)
|
|
||||||
}
|
|
||||||
if (rx != null && rx.isConnected && rxMode != null) {
|
|
||||||
Log.d(tag, "Setting RX mode: $rxMode")
|
|
||||||
rx.setMode(rxMode)
|
|
||||||
}
|
|
||||||
if (txMode?.uppercase() == "FM") {
|
|
||||||
_state.value.ctcssTone?.let { tone ->
|
|
||||||
Log.d(tag, "Setting CTCSS: ${tone}Hz")
|
|
||||||
tx?.setCtcssTone(tone)
|
|
||||||
tx?.setCtcssMode(true)
|
|
||||||
}
|
}
|
||||||
}
|
// Set CTCSS if FM
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
|
||||||
var lastSetTxFreq = 0.0
|
var lastSetTxFreq = 0.0
|
||||||
var lastSetRxFreq = 0.0
|
var lastSetRxFreq = 0.0
|
||||||
var tuningRadio = ""
|
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
||||||
var lastReadFreq = 0L
|
var lastReadFreq = 0L
|
||||||
var stableCount = 0
|
var stableCount = 0
|
||||||
|
|
||||||
while (currentCoroutineContext().isActive) {
|
while (isActive) {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
if (!currentState.isActive) break
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
val satPass = currentState.currentPass ?: break
|
val satPass = currentState.currentPass ?: break
|
||||||
val xpdr = currentState.selectedTransponder ?: break
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
val timeNow = System.currentTimeMillis()
|
||||||
val txNow = txController
|
|
||||||
val rxNow = rxController
|
|
||||||
val v = pos.distanceRate * 1000.0
|
|
||||||
|
|
||||||
if (tuningRadio.isNotEmpty()) {
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
||||||
val radio = if (tuningRadio == "tx") txNow else rxNow
|
val tx = txController
|
||||||
if (radio != null && radio.isConnected) {
|
val rx = rxController
|
||||||
val read = radio.readFrequencyAndMode()
|
val hasUplink = txBaseFreq != null
|
||||||
if (read != null) {
|
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||||
val (freq, _) = read
|
val v = pos.distanceRate * 1000.0
|
||||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
|
||||||
else { stableCount = 0; lastReadFreq = freq }
|
if (tuningRadio.isNotEmpty()) {
|
||||||
if (stableCount >= 2) {
|
// --- User is tuning: keep reading, wait for stabilization ---
|
||||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
val radio = if (tuningRadio == "tx") tx else rx
|
||||||
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
if (radio != null && radio.isConnected) {
|
||||||
if (newBase > 0) {
|
val readResult = radio.readFrequencyAndMode()
|
||||||
txBaseFreq = newBase
|
if (readResult != null) {
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
val (freq, _) = readResult
|
||||||
Log.i(tag, "TX tuning done → base=$newBase")
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
||||||
}
|
stableCount++
|
||||||
} else if (tuningRadio == "rx") {
|
} else {
|
||||||
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
stableCount = 0
|
||||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
lastReadFreq = freq
|
||||||
if (newTxBase != null && newTxBase > 0) {
|
}
|
||||||
txBaseFreq = newTxBase
|
// Stable for 2 reads → user stopped turning
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
if (stableCount >= 2) {
|
||||||
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
}
|
val newBase = (freq.toDouble() * c / (c + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- Normal tracking: read, detect changes, command ---
|
||||||
|
|
||||||
|
// TX dial feedback
|
||||||
|
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
||||||
|
val readResult = tx.readFrequencyAndMode()
|
||||||
|
if (readResult != null) {
|
||||||
|
val (actualTxFreq, _) = readResult
|
||||||
|
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = actualTxFreq
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RX dial feedback (only if TX not tuning)
|
||||||
|
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
|
||||||
|
val readResult = rx.readFrequencyAndMode()
|
||||||
|
if (readResult != null) {
|
||||||
|
val (actualRxFreq, _) = readResult
|
||||||
|
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = actualRxFreq
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
||||||
}
|
}
|
||||||
tuningRadio = ""
|
|
||||||
stableCount = 0
|
|
||||||
lastSetTxFreq = 0.0
|
|
||||||
lastSetRxFreq = 0.0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Detect manual dial changes
|
// Compute Doppler-corrected frequencies
|
||||||
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
val read = txNow.readFrequencyAndMode()
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
tuningRadio = "tx"
|
} else {
|
||||||
lastReadFreq = read.first
|
xpdr.downlinkLow
|
||||||
stableCount = 0
|
}
|
||||||
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
// Command radios (only when not tuning)
|
||||||
|
if (tuningRadio.isEmpty()) {
|
||||||
|
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
||||||
|
tx.setFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
||||||
|
rx.setFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
|
|
||||||
val read = rxNow.readFrequencyAndMode()
|
|
||||||
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "rx"
|
|
||||||
lastReadFreq = read.first
|
|
||||||
stableCount = 0
|
|
||||||
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
_state.update {
|
||||||
val rxBaseFreq = if (txBaseFreq != null) {
|
it.copy(
|
||||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
txConnected = tx?.isConnected ?: false,
|
||||||
} else xpdr.downlinkLow
|
rxConnected = rx?.isConnected ?: false,
|
||||||
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
if (tuningRadio.isEmpty()) {
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
txNow.setFrequency(txRadioFreq)
|
distance = pos.distance
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
)
|
||||||
}
|
}
|
||||||
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
|
||||||
rxNow.setFrequency(rxRadioFreq)
|
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.update {
|
delay(1000)
|
||||||
it.copy(
|
|
||||||
txConnected = txNow?.isConnected ?: false,
|
|
||||||
rxConnected = rxNow?.isConnected ?: false,
|
|
||||||
txFrequencyHz = txRadioFreq,
|
|
||||||
rxFrequencyHz = rxRadioFreq,
|
|
||||||
azimuth = Math.toDegrees(pos.azimuth),
|
|
||||||
elevation = Math.toDegrees(pos.elevation),
|
|
||||||
distance = pos.distance
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── IC-705 split-radio tracking ─────────────────────────────────────────
|
|
||||||
|
|
||||||
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
|
||||||
val radio = txController ?: return
|
|
||||||
if (!radio.isConnected) return
|
|
||||||
|
|
||||||
val txMode = transponder.uplinkMode
|
|
||||||
val rxMode = transponder.downlinkMode
|
|
||||||
?: transponder.uplinkMode?.let {
|
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute nominal base frequencies
|
|
||||||
val txCenter = when {
|
|
||||||
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
|
||||||
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
|
||||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val rxNominal = if (txCenter != null) {
|
|
||||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
|
||||||
} else transponder.downlinkLow
|
|
||||||
|
|
||||||
val txBase = initialTxBaseFreqHz ?: txCenter
|
|
||||||
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
|
|
||||||
|
|
||||||
// ── Initial setup sequence ──────────────────────────────────────────
|
|
||||||
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
|
|
||||||
// ACK from each command gates the next — no fixed delays needed.
|
|
||||||
|
|
||||||
// VFO-A = RX (downlink)
|
|
||||||
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
|
|
||||||
radio.setVfo(vfoA = true)
|
|
||||||
if (rxNominal != null) {
|
|
||||||
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
|
|
||||||
radio.setBand(rxNominal)
|
|
||||||
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
|
|
||||||
radio.setFrequency(rxNominal)
|
|
||||||
}
|
|
||||||
if (rxMode != null) {
|
|
||||||
Log.d(tag, "Split init: VFO-A mode=$rxMode")
|
|
||||||
radio.setMode(rxMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VFO-B = TX (uplink)
|
|
||||||
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
|
|
||||||
radio.setVfo(vfoA = false)
|
|
||||||
if (txBase != null) {
|
|
||||||
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
|
|
||||||
radio.setBand(txBase)
|
|
||||||
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
|
|
||||||
radio.setFrequency(txBase)
|
|
||||||
}
|
|
||||||
if (txMode != null) {
|
|
||||||
Log.d(tag, "Split init: VFO-B mode=$txMode")
|
|
||||||
radio.setMode(txMode)
|
|
||||||
}
|
|
||||||
if (txMode?.uppercase() == "FM") {
|
|
||||||
val tone = _state.value.ctcssTone
|
|
||||||
if (tone != null) {
|
|
||||||
Log.d(tag, "Split init: CTCSS=${tone}Hz")
|
|
||||||
radio.setCtcssTone(tone)
|
|
||||||
radio.setCtcssMode(true)
|
|
||||||
} else {
|
|
||||||
radio.setCtcssMode(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable SPLIT on VFO-A (return display to RX VFO first)
|
|
||||||
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
|
|
||||||
radio.setVfo(vfoA = true)
|
|
||||||
radio.setSplitMode(enabled = true)
|
|
||||||
|
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
|
|
||||||
Log.i(tag, "IC-705 split init done — entering tracking loop")
|
|
||||||
|
|
||||||
// ── Tracking loop with tuning detection ─────────────────────────────
|
|
||||||
var lastSetTxFreq = 0.0
|
|
||||||
var lastSetRxFreq = 0.0
|
|
||||||
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
|
|
||||||
var lastReadFreq = 0L
|
|
||||||
var stableCount = 0
|
|
||||||
|
|
||||||
while (currentCoroutineContext().isActive) {
|
|
||||||
val currentState = _state.value
|
|
||||||
if (!currentState.isActive) break
|
|
||||||
|
|
||||||
val satPass = currentState.currentPass ?: break
|
|
||||||
val xpdr = currentState.selectedTransponder ?: break
|
|
||||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
|
||||||
val v = pos.distanceRate * 1000.0
|
|
||||||
|
|
||||||
if (tuningRadio.isNotEmpty()) {
|
|
||||||
// User is tuning — wait for frequency to stabilize
|
|
||||||
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
|
|
||||||
if (readFreq != null) {
|
|
||||||
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
|
|
||||||
else { stableCount = 0; lastReadFreq = readFreq }
|
|
||||||
|
|
||||||
if (stableCount >= 2) {
|
|
||||||
// Frequency stable — reverse-calculate base frequency
|
|
||||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
|
||||||
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
|
||||||
if (newBase > 0) {
|
|
||||||
txBaseFreq = newBase
|
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
|
||||||
Log.i(tag, "Split TX tuning done → base=$newBase")
|
|
||||||
}
|
|
||||||
} else if (tuningRadio == "rx") {
|
|
||||||
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
|
||||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
|
||||||
if (newTxBase != null && newTxBase > 0) {
|
|
||||||
txBaseFreq = newTxBase
|
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
|
||||||
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tuningRadio = ""
|
|
||||||
stableCount = 0
|
|
||||||
lastSetTxFreq = 0.0
|
|
||||||
lastSetRxFreq = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Detect manual dial changes
|
|
||||||
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
|
|
||||||
val readTx = radio.readTxVfoFrequency()
|
|
||||||
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "tx"
|
|
||||||
lastReadFreq = readTx
|
|
||||||
stableCount = 0
|
|
||||||
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
|
|
||||||
val readRx = radio.readWorkingFrequency()
|
|
||||||
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "rx"
|
|
||||||
lastReadFreq = readRx
|
|
||||||
stableCount = 0
|
|
||||||
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine Doppler-corrected frequencies
|
|
||||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
|
||||||
val rxBaseCalc = if (txBaseFreq != null) {
|
|
||||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
|
||||||
} else xpdr.downlinkLow
|
|
||||||
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
|
|
||||||
|
|
||||||
if (radio.isConnected && tuningRadio.isEmpty()) {
|
|
||||||
// Update both VFOs every cycle — no PTT polling needed.
|
|
||||||
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
|
||||||
if (rxRadioFreq != null) {
|
|
||||||
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
|
||||||
radio.setWorkingFrequency(rxRadioFreq)
|
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
|
||||||
}
|
|
||||||
if (txRadioFreq != null) {
|
|
||||||
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
|
||||||
radio.setTxVfoFrequency(txRadioFreq)
|
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
txConnected = radio.isConnected,
|
|
||||||
rxConnected = false, // single radio
|
|
||||||
txFrequencyHz = txRadioFreq,
|
|
||||||
rxFrequencyHz = rxRadioFreq,
|
|
||||||
azimuth = Math.toDegrees(pos.azimuth),
|
|
||||||
elevation = Math.toDegrees(pos.elevation),
|
|
||||||
distance = pos.distance
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Other IRadioTrackingService methods ─────────────────────────────────
|
|
||||||
|
|
||||||
override fun stopTracking() {
|
override fun stopTracking() {
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
@@ -493,6 +288,7 @@ class RadioTrackingService(
|
|||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
rxMode?.let { rx?.setMode(it) }
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
@@ -506,17 +302,21 @@ class RadioTrackingService(
|
|||||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
// Show nominal frequencies immediately
|
||||||
val rxNominal = if (txCenter != null) {
|
val rxNominal = if (txCenter != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
} else transponder.downlinkLow
|
} else {
|
||||||
|
// Downlink-only transponder (beacon etc.) - use downlink directly
|
||||||
|
transponder.downlinkLow
|
||||||
|
}
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txCenter,
|
txBaseFrequencyHz = txCenter,
|
||||||
txFrequencyHz = txCenter,
|
txFrequencyHz = txCenter,
|
||||||
rxFrequencyHz = rxNominal,
|
rxFrequencyHz = rxNominal,
|
||||||
txMode = transponder.uplinkMode,
|
txMode = transponder.uplinkMode,
|
||||||
rxMode = transponder.downlinkMode
|
rxMode = transponder.downlinkMode
|
||||||
?: transponder.uplinkMode?.let { m ->
|
?: transponder.uplinkMode?.let { m ->
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||||
}
|
}
|
||||||
@@ -553,4 +353,5 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -26,10 +26,8 @@ import androidx.room.Room
|
|||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
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.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
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.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
@@ -41,7 +39,6 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
@@ -67,14 +64,12 @@ import okhttp3.OkHttpClient
|
|||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
private val localSource = provideLocalSource()
|
private val localSource = provideLocalSource()
|
||||||
private val remoteSource = provideRemoteSource()
|
|
||||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||||
override val settingsRepo = provideSettingsRepo()
|
override val settingsRepo = provideSettingsRepo()
|
||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
@@ -106,31 +101,20 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
rc.rotatorAddress,
|
rc.rotatorAddress,
|
||||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
rc.frequencyAddress,
|
rc.frequencyAddress,
|
||||||
rc.frequencyPort.toIntOrNull() ?: 0,
|
rc.frequencyPort.toIntOrNull() ?: 0
|
||||||
rc.frequencyOffsetHz
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideTxRadioController(): IRadioController {
|
override fun provideTxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val settings = settingsRepo.radioControlSettings.value
|
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
||||||
val address = settings.txRadioAddress
|
return Ft817Controller(manager, address)
|
||||||
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
|
||||||
Ic705Controller(manager, address)
|
|
||||||
} else {
|
|
||||||
Ft817Controller(manager, address)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideRxRadioController(): IRadioController {
|
override fun provideRxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val settings = settingsRepo.radioControlSettings.value
|
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
||||||
val address = settings.rxRadioAddress
|
return Ft817Controller(manager, address)
|
||||||
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
|
||||||
Ic705Controller(manager, address)
|
|
||||||
} else {
|
|
||||||
Ft817Controller(manager, address)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
@@ -139,13 +123,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
return SensorsRepo(manager, displayManager)
|
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 {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
|
|||||||
-166
@@ -1,166 +0,0 @@
|
|||||||
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
|
|
||||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
|
||||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
|
||||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
|
||||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, 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 (_: 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 (_: 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 (_: Exception) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build one SatStatus (5 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 utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
|
||||||
val labels = (0 until 3).map { d ->
|
|
||||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
|
||||||
formatDayLabel(utc)
|
|
||||||
}
|
|
||||||
return names.map { name ->
|
|
||||||
val slots = (0 until 36).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 3).map { d ->
|
|
||||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
|
||||||
}
|
|
||||||
SatStatus(name = name, days = days)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatDayLabel(calendar: Calendar): String {
|
|
||||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
|
||||||
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
|
||||||
} else {
|
|
||||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
||||||
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+31
-55
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -39,51 +40,48 @@ class DatabaseRepo(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : IDatabaseRepo {
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
|
private val customSourceType = "Other"
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertEntries(entries)
|
localSource.insertEntries(entries)
|
||||||
importedCount = entries.size
|
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
importedCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertRadios(transceivers)
|
localSource.insertRadios(transceivers)
|
||||||
importedCount = transceivers.size
|
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
importedCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val settings = settingsRepo.dataSourcesSettings.value
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
val tleUrls = buildMap {
|
||||||
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
putAll(Sources.satelliteDataUrls)
|
||||||
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||||
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
}.filterValues { it.isNotBlank() }
|
||||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
val radioUrls = buildMap {
|
||||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
putAll(Sources.transceiversDataUrls)
|
||||||
val tleResults = tleJobs.awaitAll()
|
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
||||||
val radioResults = radioJobs.awaitAll()
|
}.filterValues { it.isNotBlank() }
|
||||||
// report the HTTP status code of every source (200, 404, ...)
|
// launch all network requests concurrently
|
||||||
settingsRepo.updateDataSourcesStatus(
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
)
|
// parse fetched data concurrently and associate with types
|
||||||
// parse fetched data concurrently, keeping the first occurrence per primary key
|
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
// so sources listed higher in the dialog take priority over lower ones
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
val importedEntries = tleResults.flatMap { (url, result) ->
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
}.distinctBy { it.catnum }
|
}
|
||||||
val importedRadios = radioResults.flatMap { (url, result) ->
|
}
|
||||||
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
|
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
|
}
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
localSource.insertEntries(importedEntries)
|
localSource.insertEntries(importedEntries)
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
@@ -96,31 +94,9 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
|
||||||
val bufferedStream = stream.buffered()
|
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
|
||||||
return when {
|
else -> dataParser.parseTLEStream(stream)
|
||||||
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
|
||||||
else -> dataParser.parseTLEStream(bufferedStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasCsvHint(url: String): Boolean {
|
|
||||||
return url.contains("FORMAT=csv", ignoreCase = true) ||
|
|
||||||
url.endsWith(".csv", ignoreCase = true) ||
|
|
||||||
url.endsWith(".csv.zip", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun looksLikeCsv(stream: InputStream): Boolean {
|
|
||||||
if (!stream.markSupported()) return false
|
|
||||||
stream.mark(4096)
|
|
||||||
val preview = ByteArray(4096)
|
|
||||||
val length = stream.read(preview)
|
|
||||||
stream.reset()
|
|
||||||
if (length <= 0) return false
|
|
||||||
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
|
||||||
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
|
||||||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
|
||||||
line.count { it == ',' } >= 4
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
|
|||||||
+8
-53
@@ -34,11 +34,8 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -65,23 +62,11 @@ class SatelliteRepo(
|
|||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
combine(
|
settingsRepo.selectedIds.collect { selectedIds ->
|
||||||
settingsRepo.selectedIds,
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
settingsRepo.stationPosition
|
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
||||||
) { selectedIds, _ -> selectedIds }
|
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
||||||
.collect { selectedIds ->
|
}
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
|
||||||
val settings = settingsRepo.passesSettings.value
|
|
||||||
calculatePasses(
|
|
||||||
time = System.currentTimeMillis(),
|
|
||||||
hoursAhead = settings.hoursAhead,
|
|
||||||
minElevation = settings.minElevation,
|
|
||||||
aosStartMinute = settings.aosStartMinute,
|
|
||||||
aosEndMinute = settings.aosEndMinute,
|
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
|
||||||
modes = settingsRepo.selectedSatModes.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||||
@@ -120,15 +105,7 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun calculatePasses(
|
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
||||||
time: Long,
|
|
||||||
hoursAhead: Int,
|
|
||||||
minElevation: Double,
|
|
||||||
aosStartMinute: Int,
|
|
||||||
aosEndMinute: Int,
|
|
||||||
invertAosTimeWindow: Boolean,
|
|
||||||
modes: List<String>
|
|
||||||
) {
|
|
||||||
_isCalculating.value = true
|
_isCalculating.value = true
|
||||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
@@ -153,40 +130,18 @@ class SatelliteRepo(
|
|||||||
val newPasses = ArrayList<OrbitalPass>()
|
val newPasses = ArrayList<OrbitalPass>()
|
||||||
for (list in passLists) {
|
for (list in passLists) {
|
||||||
for (pass in list) {
|
for (pass in list) {
|
||||||
if (
|
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
|
||||||
pass.losTime > time
|
|
||||||
&& pass.aosTime < timeFuture
|
|
||||||
&& pass.maxElevation > minElevation
|
|
||||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
|
||||||
) {
|
|
||||||
newPasses.add(pass)
|
newPasses.add(pass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newPasses.sortBy { it.aosTime }
|
newPasses.sortBy { it.aosTime }
|
||||||
delay(1000.milliseconds) // Simulate loading time for better UX
|
delay(1000) // Simulate loading time for better UX
|
||||||
_passes.update { newPasses }
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isAosInRange(
|
|
||||||
aosTime: Long,
|
|
||||||
aosStartMinute: Int,
|
|
||||||
aosEndMinute: Int,
|
|
||||||
invertAosTimeWindow: Boolean
|
|
||||||
): Boolean {
|
|
||||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
|
||||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
|
||||||
val aosMinute = (localMillis / 60_000L).toInt()
|
|
||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
|
||||||
aosMinute in aosStartMinute..aosEndMinute
|
|
||||||
} else {
|
|
||||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
|
||||||
}
|
|
||||||
return if (invertAosTimeWindow) !inRange else inRange
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
||||||
val passes = mutableListOf<OrbitalPass>()
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
val endDate = time + hours * 60L * 60L * 1000L
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
|
|||||||
+13
-16
@@ -38,16 +38,16 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
|
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||||
private val currentQuery = MutableStateFlow("")
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
// Resolve type IDs once when types change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||||
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||||
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = localSource.getIdsWithModes(list)
|
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -56,18 +56,14 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithModes.map { items ->
|
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||||
filterByQuery(items, query).sortedWith(
|
|
||||||
compareByDescending<SatItem> { it.isSelected }
|
|
||||||
.thenBy { it.name }
|
|
||||||
.thenBy { it.catnum }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
override fun getCurrentTypes() = currentTypes.value
|
||||||
|
|
||||||
override fun getModesList() = Sources.satelliteModes
|
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||||
|
removeAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
@@ -77,8 +73,9 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setModes(modes: List<String>) {
|
override suspend fun setTypes(types: List<String>) {
|
||||||
settingsRepo.setSelectedSatModes(modes)
|
currentTypes.value = types
|
||||||
|
settingsRepo.setSelectedTypes(types)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
|
|||||||
+61
-148
@@ -25,21 +25,19 @@ import androidx.core.location.LocationListenerCompat
|
|||||||
import androidx.core.location.LocationManagerCompat
|
import androidx.core.location.LocationManagerCompat
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
@@ -57,9 +55,6 @@ class SettingsRepo(
|
|||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
|
||||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
|
||||||
private val keyFilterAosInvert = "filterAosInvert"
|
|
||||||
private val keyNumberOfRadios = "numberOfRadios"
|
private val keyNumberOfRadios = "numberOfRadios"
|
||||||
private val keyNumberOfSatellites = "numberOfSatellites"
|
private val keyNumberOfSatellites = "numberOfSatellites"
|
||||||
private val keyRotatorAddress = "rotatorAddress"
|
private val keyRotatorAddress = "rotatorAddress"
|
||||||
@@ -70,9 +65,9 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyAddress = "frequencyAddress"
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedSatModes = "selectedSatModes"
|
private val keySelectedTypes = "selectedTypes"
|
||||||
|
private val keySelectedModes = "selectedModes"
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
@@ -88,22 +83,19 @@ class SettingsRepo(
|
|||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyMapSource = "mapSource"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyTiandituKey = "tiandituKey"
|
||||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keySatelliteUrls = "satelliteUrls"
|
private val keyTleUrl = "tleUrl"
|
||||||
private val keyTransceiversUrls = "transceiversUrls"
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
private val keySatelliteEnabled = "satelliteEnabled"
|
|
||||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
private val separatorUrl = "\n"
|
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -111,10 +103,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedSatModes(modes: List<String>) {
|
override fun setSelectedTypes(types: List<String>) {
|
||||||
val modesString = modes.joinToString(separatorComma)
|
val typesString = types.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedSatModes, modesString) }
|
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||||
_satelliteModeSelection.value = modes
|
_typesSelection.value = types
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -123,10 +115,10 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedSatModes(): List<String> {
|
private fun getSelectedTypes(): List<String> {
|
||||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||||
if (modesString.isNullOrEmpty()) return emptyList()
|
if (typesString.isNullOrEmpty()) return emptyList()
|
||||||
return modesString.split(separatorComma).sorted()
|
return typesString.split(separatorComma)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -138,9 +130,7 @@ class SettingsRepo(
|
|||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
|
||||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,17 +138,9 @@ class SettingsRepo(
|
|||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
|
||||||
return PassesSettings(
|
|
||||||
showDeepSpace,
|
|
||||||
hoursAhead,
|
|
||||||
minElevation,
|
|
||||||
aosStartMinute,
|
|
||||||
aosEndMinute,
|
|
||||||
invertAosTimeWindow
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -237,6 +219,25 @@ class SettingsRepo(
|
|||||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
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 {
|
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||||
@@ -281,10 +282,6 @@ class SettingsRepo(
|
|||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
|
||||||
Constants.FREQ_OFFSET_MIN_HZ,
|
|
||||||
Constants.FREQ_OFFSET_MAX_HZ
|
|
||||||
)
|
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyRotatorState, settings.rotatorState)
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
@@ -294,7 +291,6 @@ class SettingsRepo(
|
|||||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
putString(keyFrequencyPort, settings.frequencyPort)
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
|
||||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
@@ -303,7 +299,7 @@ class SettingsRepo(
|
|||||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
}
|
}
|
||||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
_rcSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -315,8 +311,6 @@ class SettingsRepo(
|
|||||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
|
||||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
|
||||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
@@ -344,10 +338,8 @@ class SettingsRepo(
|
|||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putString(keyMapSource, MapSource.normalize(new.mapSource))
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putString(keyTiandituKey, new.tiandituKey)
|
||||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
|
||||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -363,10 +355,8 @@ class SettingsRepo(
|
|||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null) ?: MapSource.TIANDITU_VECTOR),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
tiandituKey = preferences.getString(keyTiandituKey, null) ?: ""
|
||||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
|
||||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -375,73 +365,21 @@ class SettingsRepo(
|
|||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
|
||||||
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
|
||||||
// invariant intact even when a default empty list is used to construct the model.
|
|
||||||
val normalized = settings.copy(
|
|
||||||
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
|
||||||
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
|
||||||
)
|
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
||||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
||||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
putString(keyTleUrl, settings.tleUrl)
|
||||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
putString(keyTransceiversUrl, settings.transceiversUrl)
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = normalized
|
_dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
val (satUrls, satEnabled) = parseSources(
|
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||||
preferences.getString(keySatelliteUrls, null),
|
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||||
preferences.getString(keySatelliteEnabled, null),
|
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
||||||
Sources.satelliteDataUrls
|
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||||
)
|
)
|
||||||
val (txUrls, txEnabled) = parseSources(
|
|
||||||
preferences.getString(keyTransceiversUrls, null),
|
|
||||||
preferences.getString(keyTransceiversEnabled, null),
|
|
||||||
Sources.transceiversDataUrls
|
|
||||||
)
|
|
||||||
return DataSourcesSettings(
|
|
||||||
satelliteUrls = satUrls,
|
|
||||||
transceiversUrls = txUrls,
|
|
||||||
satelliteEnabled = satEnabled,
|
|
||||||
transceiversEnabled = txEnabled
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseSources(
|
|
||||||
storedUrls: String?,
|
|
||||||
storedEnabled: String?,
|
|
||||||
defaults: List<String>
|
|
||||||
): Pair<List<String>, List<Boolean>> {
|
|
||||||
if (storedUrls == null) return defaults to defaults.map { true }
|
|
||||||
val urls = storedUrls.split(separatorUrl)
|
|
||||||
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
|
|
||||||
val filteredUrls = mutableListOf<String>()
|
|
||||||
val filteredFlags = mutableListOf<Boolean>()
|
|
||||||
urls.forEachIndexed { index, url ->
|
|
||||||
if (url.isNotBlank()) {
|
|
||||||
filteredUrls.add(url)
|
|
||||||
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filteredUrls to filteredFlags
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
|
||||||
if (flags.size >= urls.size) return flags.take(urls.size)
|
|
||||||
return flags + List(urls.size - flags.size) { true }
|
|
||||||
}
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # Data sources status
|
|
||||||
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
|
||||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
|
||||||
|
|
||||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
|
||||||
_dataSourcesStatus.value = status
|
|
||||||
}
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -452,7 +390,6 @@ class SettingsRepo(
|
|||||||
private val keyTxRadioName = "txRadioName"
|
private val keyTxRadioName = "txRadioName"
|
||||||
private val keyRxRadioName = "rxRadioName"
|
private val keyRxRadioName = "rxRadioName"
|
||||||
private val keyRadioBaudRate = "radioBaudRate"
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
private val keyRadioSplitMode = "radioSplitMode"
|
|
||||||
|
|
||||||
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
@@ -466,42 +403,18 @@ class SettingsRepo(
|
|||||||
putString(keyTxRadioName, settings.txRadioName)
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
putString(keyRxRadioName, settings.rxRadioName)
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
putInt(keyRadioBaudRate, settings.baudRate)
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
putBoolean(keyRadioSplitMode, settings.splitMode)
|
|
||||||
}
|
}
|
||||||
_radioControlSettings.value = settings
|
_radioControlSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
|
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
|
||||||
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
|
||||||
)
|
)
|
||||||
|
//endregion
|
||||||
private val keySatelliteOffsets = "satelliteOffsets"
|
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String {
|
|
||||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
|
||||||
return try {
|
|
||||||
JSONObject(json).optString(catnum.toString(), "")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
|
||||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
|
||||||
val updated = try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
|
||||||
obj.toString()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
"""{"$catnum": "$offset"}"""
|
|
||||||
}
|
|
||||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,11 @@ package com.rtbishop.look4sat.core.data.source
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -43,57 +43,15 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
val response = httpClient.newCall(networkRequest).execute()
|
httpClient.newCall(networkRequest).execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) return@withContext null
|
||||||
val code = response.code
|
ByteArrayInputStream(response.body.bytes())
|
||||||
response.close()
|
|
||||||
return@withContext NetworkResult(code, null)
|
|
||||||
}
|
}
|
||||||
// Return the body stream directly as the caller is responsible for closing it
|
|
||||||
// That returns the connection to OkHttp's pool
|
|
||||||
val body = response.body
|
|
||||||
if (body == null) {
|
|
||||||
response.close()
|
|
||||||
return@withContext NetworkResult(response.code, null)
|
|
||||||
}
|
|
||||||
NetworkResult(response.code, body.byteStream().buffered())
|
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
NetworkResult(NetworkResult.CONNECTION_ERROR, 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
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-20
@@ -1,29 +1,30 @@
|
|||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data
|
||||||
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
class Ft817CatProtocolTest {
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_145500000() {
|
fun encodeFrequencyBcd_145500000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_435100000() {
|
fun encodeFrequencyBcd_435100000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_7074000() {
|
fun encodeFrequencyBcd_7074000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -41,21 +42,21 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x01.toByte(), cmd[4])
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_usb() {
|
fun buildSetModeCommand_usb() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_fm() {
|
fun buildSetModeCommand_fm() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -66,19 +67,19 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_67_0() {
|
fun encodeCtcssTone_67_0() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_74_4() {
|
fun encodeCtcssTone_74_4() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_141_3() {
|
fun encodeCtcssTone_141_3() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -86,13 +87,13 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x0B.toByte(), cmd[4])
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildCtcssModeCommand_enable() {
|
fun buildCtcssModeCommand_enable() {
|
||||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -107,7 +108,6 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
result ?: return
|
|
||||||
assertEquals(145500000L, result.first)
|
assertEquals(145500000L, result.first)
|
||||||
assertEquals("USB", result.second)
|
assertEquals("USB", result.second)
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,6 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
result ?: return
|
|
||||||
assertEquals(146000000L, result.first)
|
assertEquals(146000000L, result.first)
|
||||||
assertEquals("FM", result.second)
|
assertEquals("FM", result.second)
|
||||||
}
|
}
|
||||||
@@ -136,7 +135,7 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildPttCommands() {
|
fun buildPttCommands() {
|
||||||
val on = Ft817CatProtocol.buildPttOnCommand()
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
val off = Ft817CatProtocol.buildPttOffCommand()
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
assertEquals(0x88.toByte(), off[4])
|
assertEquals(0x88.toByte(), off[4])
|
||||||
@@ -145,6 +144,6 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildReadCommand() {
|
fun buildReadCommand() {
|
||||||
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-239
@@ -1,239 +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.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.OrbitalData
|
|
||||||
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 com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class DatabaseRepoTest {
|
|
||||||
|
|
||||||
private val dispatcher = StandardTestDispatcher()
|
|
||||||
private val dataParser = DataParser(dispatcher)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
|
||||||
val uri = "content://look4sat/import/satellites"
|
|
||||||
val localSource = FakeLocalSource()
|
|
||||||
val remoteSource = FakeRemoteSource().apply {
|
|
||||||
fileStreams[uri] = { validCsvStream() }
|
|
||||||
}
|
|
||||||
val settingsRepo = FakeSettingsRepo()
|
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
|
||||||
|
|
||||||
repository.updateTLEFromFile(uri)
|
|
||||||
|
|
||||||
assertEquals(1, localSource.insertedEntries.size)
|
|
||||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
|
||||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `manual satellite import keeps tle support`() = runTest(dispatcher) {
|
|
||||||
val uri = "content://look4sat/import/legacy"
|
|
||||||
val localSource = FakeLocalSource()
|
|
||||||
val remoteSource = FakeRemoteSource().apply {
|
|
||||||
fileStreams[uri] = { validTleStream() }
|
|
||||||
}
|
|
||||||
val settingsRepo = FakeSettingsRepo()
|
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
|
||||||
|
|
||||||
repository.updateTLEFromFile(uri)
|
|
||||||
|
|
||||||
assertEquals(1, localSource.insertedEntries.size)
|
|
||||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `custom data source imports omm csv from web`() = runTest(dispatcher) {
|
|
||||||
val customCsvUrl = "https://example.com/custom-omm.csv"
|
|
||||||
val localSource = FakeLocalSource()
|
|
||||||
val remoteSource = FakeRemoteSource().apply {
|
|
||||||
networkStreams[customCsvUrl] = { validCsvStream() }
|
|
||||||
}
|
|
||||||
val settingsRepo = FakeSettingsRepo(
|
|
||||||
dataSources = DataSourcesSettings(
|
|
||||||
satelliteUrls = listOf(customCsvUrl),
|
|
||||||
transceiversUrls = emptyList()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
|
||||||
|
|
||||||
repository.updateFromRemote()
|
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
|
||||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
|
||||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
|
||||||
""".trimIndent().byteInputStream()
|
|
||||||
|
|
||||||
private fun validTleStream(): InputStream = """
|
|
||||||
ISS (ZARYA)
|
|
||||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
|
||||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
|
||||||
""".trimIndent().byteInputStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeRemoteSource : IRemoteSource {
|
|
||||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
|
||||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
|
||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): NetworkResult {
|
|
||||||
val stream = networkStreams[url]?.invoke()
|
|
||||||
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getAmSatCatalog(): String? = null
|
|
||||||
|
|
||||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
|
||||||
val insertedEntries = mutableListOf<OrbitalData>()
|
|
||||||
private val insertedRadios = mutableListOf<SatRadio>()
|
|
||||||
|
|
||||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
|
||||||
|
|
||||||
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
|
||||||
insertedEntries += entries
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteEntries() {
|
|
||||||
insertedEntries.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
|
||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
|
||||||
insertedRadios += radios
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteRadios() {
|
|
||||||
insertedRadios.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
|
||||||
|
|
||||||
override val appVersionName: String = "test"
|
|
||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
|
||||||
|
|
||||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
|
||||||
|
|
||||||
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, "", "", "", 0L, false, "", "", "", false, "", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
|
||||||
OtherSettings(false, false, false, false, false, false, false, false)
|
|
||||||
)
|
|
||||||
|
|
||||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
|
||||||
|
|
||||||
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
|
||||||
|
|
||||||
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>) = Unit
|
|
||||||
|
|
||||||
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) {
|
|
||||||
dataSourcesSettings.value = settings
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
|
||||||
dataSourcesStatus.value = status
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
|
||||||
return DataSourcesSettings(
|
|
||||||
satelliteUrls = emptyList(),
|
|
||||||
transceiversUrls = emptyList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-181
@@ -1,181 +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.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, "", "", "", 0L, false, "", "", "", false, "", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
|
||||||
OtherSettings(false, false, false, false, false, false, false, false)
|
|
||||||
)
|
|
||||||
|
|
||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
|
||||||
DataSourcesSettings(emptyList(), emptyList())
|
|
||||||
)
|
|
||||||
|
|
||||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
|
||||||
|
|
||||||
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 updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,23 +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.model
|
|
||||||
|
|
||||||
object Constants {
|
|
||||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
|
||||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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, 5 days of state */
|
|
||||||
data class SatStatus(
|
|
||||||
val name: String, // "AO-123_[FM]"
|
|
||||||
val days: List<SatDay> // 5 天(新→旧)
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Overall page parse result */
|
|
||||||
data class SatStatusPage(
|
|
||||||
val fetchedAtUtcMs: Long,
|
|
||||||
val statuses: List<SatStatus>,
|
|
||||||
val reports: Map<String, SatReport> // id → 报告
|
|
||||||
)
|
|
||||||
@@ -27,9 +27,7 @@ data class PassesSettings(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val hoursAhead: Int,
|
val hoursAhead: Int,
|
||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val aosStartMinute: Int = 0,
|
val selectedModes: List<String>
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
|
||||||
val invertAosTimeWindow: Boolean = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -41,7 +39,6 @@ data class RCSettings(
|
|||||||
val frequencyAddress: String,
|
val frequencyAddress: String,
|
||||||
val frequencyPort: String,
|
val frequencyPort: String,
|
||||||
val frequencyFormat: String,
|
val frequencyFormat: String,
|
||||||
val frequencyOffsetHz: Long = 0L,
|
|
||||||
val bluetoothRotatorState: Boolean,
|
val bluetoothRotatorState: Boolean,
|
||||||
val bluetoothRotatorFormat: String,
|
val bluetoothRotatorFormat: String,
|
||||||
val bluetoothRotatorName: String,
|
val bluetoothRotatorName: String,
|
||||||
@@ -61,28 +58,30 @@ data class OtherSettings(
|
|||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val mapSource: String = MapSource.TIANDITU_VECTOR,
|
||||||
val highElevation: Double = 45.0,
|
val tiandituKey: String = ""
|
||||||
val radarCompassOffset: Float = 0f,
|
|
||||||
val radarCompassOffsetElev: Float = 0f
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
object MapSource {
|
||||||
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
|
const val OSM = "osm"
|
||||||
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
|
const val TIANDITU = "tianditu"
|
||||||
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
|
const val TIANDITU_VECTOR = "tianditu_vector"
|
||||||
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
|
const val TIANDITU_IMAGE = "tianditu_image"
|
||||||
*/
|
|
||||||
data class DataSourcesSettings(
|
fun normalize(source: String): String = when (source) {
|
||||||
val satelliteUrls: List<String>,
|
TIANDITU -> TIANDITU_VECTOR
|
||||||
val transceiversUrls: List<String>,
|
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
||||||
val satelliteEnabled: List<Boolean> = emptyList(),
|
else -> TIANDITU_VECTOR
|
||||||
val transceiversEnabled: List<Boolean> = emptyList()
|
}
|
||||||
) {
|
|
||||||
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
|
||||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class DataSourcesSettings(
|
||||||
|
val useCustomTLE: Boolean,
|
||||||
|
val useCustomTransceivers: Boolean,
|
||||||
|
val tleUrl: String,
|
||||||
|
val transceiversUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
val radioModel: String,
|
val radioModel: String,
|
||||||
@@ -90,20 +89,12 @@ data class RadioControlSettings(
|
|||||||
val rxRadioAddress: String,
|
val rxRadioAddress: String,
|
||||||
val txRadioName: String,
|
val txRadioName: String,
|
||||||
val rxRadioName: String,
|
val rxRadioName: String,
|
||||||
val baudRate: Int,
|
val baudRate: Int
|
||||||
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
|
||||||
val splitMode: Boolean = false
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
val SUPPORTED_RADIOS = listOf(
|
||||||
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
"Yaesu FT-817/818",
|
||||||
const val MODEL_ICOM_IC705 = "Icom IC-705"
|
"Yaesu FT-857/897"
|
||||||
|
)
|
||||||
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
|
|
||||||
|
|
||||||
/** Baud rates available for Yaesu radios. */
|
|
||||||
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
|
|
||||||
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
|
|
||||||
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-6
@@ -424,12 +424,8 @@ object CelestialComputer {
|
|||||||
}
|
}
|
||||||
if (sunrise == 0.0) sunrise = daynum
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||||
// Start from just after sunrise (small offset) so the sun is clearly above
|
daynum = sunrise
|
||||||
// the threshold. This prevents a bug where Phase 3 converges to a point
|
|
||||||
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
|
||||||
// to the same time as sunrise, producing identical sunrise/sunset times.
|
|
||||||
daynum = sunrise + 0.001
|
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
|||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
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?
|
|
||||||
}
|
|
||||||
+2
-2
@@ -18,8 +18,8 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
interface IDatabaseRepo {
|
interface IDatabaseRepo {
|
||||||
suspend fun updateTLEFromFile(uri: String): Int
|
suspend fun updateTLEFromFile(uri: String)
|
||||||
suspend fun updateTransceiversFromFile(uri: String): Int
|
suspend fun updateTransceiversFromFile(uri: String)
|
||||||
suspend fun updateFromRemote()
|
suspend fun updateFromRemote()
|
||||||
suspend fun clearAllData()
|
suspend fun clearAllData()
|
||||||
}
|
}
|
||||||
-2
@@ -29,7 +29,6 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
@@ -40,7 +39,6 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
-47
@@ -38,51 +38,4 @@ interface IRadioController {
|
|||||||
suspend fun pttOn(): Boolean
|
suspend fun pttOn(): Boolean
|
||||||
|
|
||||||
suspend fun pttOff(): Boolean
|
suspend fun pttOff(): Boolean
|
||||||
|
|
||||||
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the band matching [frequencyHz] via the band stacking register.
|
|
||||||
* Must be called before [setFrequency] and [setMode] when first tracking.
|
|
||||||
* Default: no-op (Yaesu radios auto-switch band via frequency).
|
|
||||||
*/
|
|
||||||
suspend fun setBand(frequencyHz: Long): Boolean = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the active VFO.
|
|
||||||
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
|
|
||||||
*/
|
|
||||||
suspend fun setVfo(vfoA: Boolean): Boolean = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
|
|
||||||
* Default: not supported.
|
|
||||||
*/
|
|
||||||
suspend fun setSplitMode(enabled: Boolean): Boolean = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
|
||||||
* Default: delegates to [setFrequency].
|
|
||||||
*/
|
|
||||||
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
|
||||||
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
|
|
||||||
* Default: delegates to [setWorkingFrequency].
|
|
||||||
*/
|
|
||||||
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
|
||||||
* Default: delegates to [readFrequencyAndMode].
|
|
||||||
*/
|
|
||||||
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
|
||||||
* Used for tuning detection in split mode.
|
|
||||||
* Default: delegates to [readWorkingFrequency].
|
|
||||||
*/
|
|
||||||
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
|
|
||||||
}
|
}
|
||||||
+1
-9
@@ -44,15 +44,7 @@ interface ISatelliteRepo {
|
|||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
|
|
||||||
/** Recalculate passes with the given filter parameters. */
|
/** Recalculate passes with the given filter parameters. */
|
||||||
suspend fun calculatePasses(
|
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
|
||||||
time: Long,
|
|
||||||
hoursAhead: Int,
|
|
||||||
minElevation: Double,
|
|
||||||
aosStartMinute: Int,
|
|
||||||
aosEndMinute: Int,
|
|
||||||
invertAosTimeWindow: Boolean,
|
|
||||||
modes: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Get the current position of a single satellite. */
|
/** Get the current position of a single satellite. */
|
||||||
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
||||||
|
|||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentModes(): List<String>
|
fun getCurrentTypes(): List<String>
|
||||||
fun getModesList(): List<String>
|
fun getTypesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||||
suspend fun setModes(modes: List<String>)
|
suspend fun setTypes(types: List<String>)
|
||||||
suspend fun setQuery(query: String)
|
suspend fun setQuery(query: String)
|
||||||
suspend fun setSelection(selectAll: Boolean)
|
suspend fun setSelection(selectAll: Boolean)
|
||||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||||
|
|||||||
+4
-12
@@ -32,9 +32,9 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
val selectedSatModes: StateFlow<List<String>>
|
val selectedTypes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedSatModes(modes: List<String>)
|
fun setSelectedTypes(types: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -51,6 +51,8 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Database update settings
|
//region # Database update settings
|
||||||
val databaseState: StateFlow<DatabaseState>
|
val databaseState: StateFlow<DatabaseState>
|
||||||
|
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
||||||
|
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
||||||
fun updateDatabaseState(state: DatabaseState)
|
fun updateDatabaseState(state: DatabaseState)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -71,18 +73,8 @@ interface ISettingsRepo {
|
|||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Data sources status
|
|
||||||
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
|
||||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Per-satellite calculator offset settings
|
|
||||||
fun getSatelliteOffset(catnum: Int): String
|
|
||||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
|
||||||
//endregion
|
|
||||||
}
|
}
|
||||||
+1
-11
@@ -19,17 +19,7 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
/** Result of a network download: the HTTP status code and the response body stream. */
|
|
||||||
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
|
||||||
companion object {
|
|
||||||
/** Code used when a request fails before any HTTP response is received. */
|
|
||||||
const val CONNECTION_ERROR = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): NetworkResult
|
suspend fun getNetworkStream(url: String): InputStream?
|
||||||
suspend fun getAmSatCatalog(): String?
|
|
||||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
|
||||||
}
|
}
|
||||||
@@ -18,25 +18,37 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
val satelliteDataUrls = listOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"db.satnogs.org/api/tle/?format=3le",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
"amsat.org/tle/current/nasabare.txt",
|
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||||
"mmccants.org/tles/classfd.zip",
|
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||||
"r4uab.ru/satonline.txt",
|
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
|
||||||
"live.ariss.org/iss.txt"
|
"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",
|
||||||
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = listOf(
|
val transceiversDataUrls = mapOf(
|
||||||
"db.satnogs.org/api/transmitters/?format=json&status=active",
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
"r4uab.ru/transmitters.json"
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -20,94 +20,39 @@ package com.rtbishop.look4sat.core.domain.sstv
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.math.PI
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class SstvFrame(
|
class SstvFrame(
|
||||||
val scopePixels: IntArray?,
|
|
||||||
val scopeWidth: Int,
|
|
||||||
val scopeHeight: Int,
|
|
||||||
val imagePixels: IntArray?,
|
val imagePixels: IntArray?,
|
||||||
val imageWidth: Int,
|
val imageWidth: Int,
|
||||||
val imageHeight: Int,
|
val imageHeight: Int,
|
||||||
val modeName: String,
|
val modeName: String
|
||||||
val imageComplete: Boolean,
|
|
||||||
val inputRms: Float,
|
|
||||||
val appliedGain: Float,
|
|
||||||
val syncHitRate: Float,
|
|
||||||
val predictedLineBursts: Int,
|
|
||||||
val maxPredictedStreak: Int,
|
|
||||||
val timingErrorSamples: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
|
|
||||||
* performance on noisy recordings.
|
|
||||||
*/
|
|
||||||
data class SstvQualityMetrics(
|
|
||||||
val syncHitRate: Float,
|
|
||||||
val predictedLineBursts: Int,
|
|
||||||
val maxPredictedStreak: Int,
|
|
||||||
val timingErrorSamples: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class LineRecoveryStrategy {
|
|
||||||
Look4SatLimited,
|
|
||||||
Robot36Compatible
|
|
||||||
}
|
|
||||||
|
|
||||||
class SstvDiagnosticsHandle internal constructor(
|
|
||||||
val enabled: Boolean,
|
|
||||||
val metrics: StateFlow<SstvQualityMetrics?>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class SstvDecoder(
|
class SstvDecoder(
|
||||||
sampleRate: Int = 44100,
|
sampleRate: Int = 44100,
|
||||||
scopeWidth: Int = 320,
|
scopeWidth: Int = 320,
|
||||||
scopeHeight: Int = 256,
|
scopeHeight: Int = 256,
|
||||||
private val channelSelect: Int = 0,
|
private val channelSelect: Int = 0
|
||||||
targetRmsLevel: Float = 0.25f,
|
|
||||||
private val includeScopeData: Boolean = false,
|
|
||||||
private val enableRmsNormalization: Boolean = true,
|
|
||||||
preFilterCutoffHz: Double = 500.0,
|
|
||||||
enablePreFilter: Boolean = true,
|
|
||||||
private val enableDiagnosticsHandle: Boolean = false,
|
|
||||||
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
|
|
||||||
) {
|
) {
|
||||||
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||||
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
||||||
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
|
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
|
||||||
private val _frames = MutableSharedFlow<SstvFrame>(
|
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||||
replay = 1,
|
replay = 1,
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
)
|
)
|
||||||
private var lastInputRms = 0f
|
|
||||||
private var lastAppliedGain = 1f
|
|
||||||
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
|
|
||||||
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
|
|
||||||
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
|
|
||||||
val frames: SharedFlow<SstvFrame> = _frames
|
val frames: SharedFlow<SstvFrame> = _frames
|
||||||
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||||
|
|
||||||
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
|
// Normalize to a fixed RMS before processing so that both direct audio
|
||||||
// weak signals and corrupt the RMS normalization baseline.
|
// coupling and air-coupled microphone input decode with equal reliability.
|
||||||
preFilter?.apply(samples)
|
normalise(samples)
|
||||||
|
|
||||||
// Optional RMS normalization: bring input to a consistent level so the
|
|
||||||
// FM demodulator operates in a predictable region. However, this amplifies
|
|
||||||
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
|
|
||||||
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
|
|
||||||
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
|
|
||||||
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
|
|
||||||
lastInputRms = gain.inputRms
|
|
||||||
lastAppliedGain = gain.appliedGain
|
|
||||||
val hasNewLines = decoder.process(samples, channelSelect)
|
val hasNewLines = decoder.process(samples, channelSelect)
|
||||||
if (hasNewLines) emitFrame()
|
if (hasNewLines) emitFrame()
|
||||||
}
|
}
|
||||||
@@ -117,134 +62,36 @@ class SstvDecoder(
|
|||||||
fun clearPixels() {
|
fun clearPixels() {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
imageBuffer.pixels.fill(0)
|
imageBuffer.pixels.fill(0)
|
||||||
decoder.resetQuality()
|
|
||||||
if (enableDiagnosticsHandle) _qualityMetrics.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export quality metrics for logging/diagnostics. Useful for profiling decode
|
|
||||||
* performance on noisy recordings. Returns null before first frame is emitted.
|
|
||||||
*/
|
|
||||||
@Suppress("unused")
|
|
||||||
fun getQualityMetrics(): SstvQualityMetrics? {
|
|
||||||
if (enableDiagnosticsHandle) return _qualityMetrics.value
|
|
||||||
val q = decoder.quality()
|
|
||||||
return SstvQualityMetrics(
|
|
||||||
syncHitRate = q.syncHitRate,
|
|
||||||
predictedLineBursts = q.predictedLineBursts,
|
|
||||||
maxPredictedStreak = q.maxPredictedStreak,
|
|
||||||
timingErrorSamples = q.timingErrorSamples
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emitFrame() {
|
private fun emitFrame() {
|
||||||
val imageWidth = imageBuffer.width
|
val imageWidth = imageBuffer.width
|
||||||
val imageHeight = imageBuffer.height
|
val imageHeight = imageBuffer.height
|
||||||
val quality = decoder.quality()
|
|
||||||
if (enableDiagnosticsHandle) {
|
|
||||||
_qualityMetrics.value = SstvQualityMetrics(
|
|
||||||
syncHitRate = quality.syncHitRate,
|
|
||||||
predictedLineBursts = quality.predictedLineBursts,
|
|
||||||
maxPredictedStreak = quality.maxPredictedStreak,
|
|
||||||
timingErrorSamples = quality.timingErrorSamples
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
|
|
||||||
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
||||||
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
||||||
// the entire array and send padding pixels to the observer.
|
// the entire array and send padding pixels to the observer.
|
||||||
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
||||||
val modeName = decoder.currentMode.name
|
val modeName = decoder.currentMode.name
|
||||||
val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
|
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
|
||||||
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
|
|
||||||
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
|
|
||||||
_frames.tryEmit(
|
|
||||||
SstvFrame(
|
|
||||||
scopePixels = scopePixels,
|
|
||||||
scopeWidth = scopeWidth,
|
|
||||||
scopeHeight = scopeHeight,
|
|
||||||
imagePixels = imagePixels,
|
|
||||||
imageWidth = imageWidth,
|
|
||||||
imageHeight = imageHeight,
|
|
||||||
modeName = modeName,
|
|
||||||
imageComplete = imageComplete,
|
|
||||||
inputRms = lastInputRms,
|
|
||||||
appliedGain = lastAppliedGain,
|
|
||||||
syncHitRate = quality.syncHitRate,
|
|
||||||
predictedLineBursts = quality.predictedLineBursts,
|
|
||||||
maxPredictedStreak = quality.maxPredictedStreak,
|
|
||||||
timingErrorSamples = quality.timingErrorSamples
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||||
// FM demodulator well above its noise floor regardless of input gain.
|
// FM demodulator well above its noise floor regardless of input gain.
|
||||||
// TUNING GUIDE:
|
private val targetRms = 0.25f
|
||||||
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
|
|
||||||
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
|
|
||||||
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
|
|
||||||
// Disable RMS normalization entirely if using a professional receiver discriminator output.
|
|
||||||
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
|
|
||||||
|
|
||||||
private class GainInfo(
|
|
||||||
val inputRms: Float,
|
|
||||||
val appliedGain: Float
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
||||||
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
||||||
private fun normalise(buffer: FloatArray): GainInfo {
|
private fun normalise(buffer: FloatArray) {
|
||||||
var sumSq = 0f
|
var sumSq = 0f
|
||||||
for (s in buffer) sumSq += s * s
|
for (s in buffer) sumSq += s * s
|
||||||
val rms = sqrt(sumSq / buffer.size)
|
val rms = sqrt(sumSq / buffer.size)
|
||||||
if (rms > 1e-6f) {
|
if (rms > 1e-6f) {
|
||||||
val gain = targetRms / rms
|
val gain = targetRms / rms
|
||||||
for (i in buffer.indices) buffer[i] *= gain
|
for (i in buffer.indices) buffer[i] *= gain
|
||||||
return GainInfo(rms, gain)
|
|
||||||
}
|
|
||||||
return GainInfo(rms, 1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
|
|
||||||
* normalization. Improves noise floor estimation and prevents low-freq noise from
|
|
||||||
* corrupting the gain calculation.
|
|
||||||
*
|
|
||||||
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
|
|
||||||
* suitable for real-time preprocessing.
|
|
||||||
*/
|
|
||||||
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
|
|
||||||
private val alpha: Float
|
|
||||||
private var prevInput = 0f
|
|
||||||
private var prevOutput = 0f
|
|
||||||
|
|
||||||
init {
|
|
||||||
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
|
|
||||||
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
|
|
||||||
alpha = omega / (omega + 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun apply(buffer: FloatArray) {
|
|
||||||
for (i in buffer.indices) {
|
|
||||||
val input = buffer[i]
|
|
||||||
prevOutput = alpha * (prevOutput + input - prevInput)
|
|
||||||
buffer[i] = prevOutput
|
|
||||||
prevInput = input
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class DecoderQuality(
|
|
||||||
val syncHitRate: Float,
|
|
||||||
val predictedLineBursts: Int,
|
|
||||||
val maxPredictedStreak: Int,
|
|
||||||
val timingErrorSamples: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
||||||
|
|
||||||
internal class SyncPulseDetector(sampleRate: Int) {
|
internal class SyncPulseDetector(sampleRate: Int) {
|
||||||
@@ -350,8 +197,7 @@ internal class DecoderEngine(
|
|||||||
private val scopeBuffer: PixelBuffer,
|
private val scopeBuffer: PixelBuffer,
|
||||||
private val imageBuffer: PixelBuffer,
|
private val imageBuffer: PixelBuffer,
|
||||||
rawName: String,
|
rawName: String,
|
||||||
sampleRate: Int,
|
sampleRate: Int
|
||||||
lineRecoveryStrategy: LineRecoveryStrategy
|
|
||||||
) {
|
) {
|
||||||
private val pixelBuffer = PixelBuffer(800, 2)
|
private val pixelBuffer = PixelBuffer(800, 2)
|
||||||
private val detector = SyncPulseDetector(sampleRate)
|
private val detector = SyncPulseDetector(sampleRate)
|
||||||
@@ -391,19 +237,6 @@ internal class DecoderEngine(
|
|||||||
private var lastSync = 0
|
private var lastSync = 0
|
||||||
private var curLineSamples: Int
|
private var curLineSamples: Int
|
||||||
private var lastOffset = 0f
|
private var lastOffset = 0f
|
||||||
private var syncChecks = 0
|
|
||||||
private var syncHits = 0
|
|
||||||
private var predictedLineBursts = 0
|
|
||||||
private var predictedStreak = 0
|
|
||||||
private var maxPredictedStreak = 0
|
|
||||||
private var timingErrorSamples = 0
|
|
||||||
|
|
||||||
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
|
|
||||||
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
|
|
||||||
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
|
|
||||||
LineRecoveryStrategy.Look4SatLimited -> 2
|
|
||||||
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
@@ -453,11 +286,6 @@ internal class DecoderEngine(
|
|||||||
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
var newLines = false
|
var newLines = false
|
||||||
val detected = detector.process(recordBuffer, channelSelect)
|
val detected = detector.process(recordBuffer, channelSelect)
|
||||||
syncChecks++
|
|
||||||
if (detected) {
|
|
||||||
syncHits++
|
|
||||||
predictedStreak = 0
|
|
||||||
}
|
|
||||||
var syncIdx = sample + detector.pulseOffset
|
var syncIdx = sample + detector.pulseOffset
|
||||||
val channels = if (channelSelect > 0) 2 else 1
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
for (j in 0 until recordBuffer.size / channels) {
|
for (j in 0 until recordBuffer.size / channels) {
|
||||||
@@ -480,10 +308,20 @@ internal class DecoderEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (handleHeader()) {
|
} else if (handleHeader()) {
|
||||||
predictedStreak = 0
|
|
||||||
newLines = true
|
newLines = true
|
||||||
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||||
newLines = decodePredictedLine()
|
copyLines(
|
||||||
|
currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
lastSync,
|
||||||
|
curLineSamples,
|
||||||
|
lastOffset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lastSync += curLineSamples; newLines = true
|
||||||
}
|
}
|
||||||
return newLines
|
return newLines
|
||||||
}
|
}
|
||||||
@@ -499,25 +337,6 @@ internal class DecoderEngine(
|
|||||||
lockMode = false
|
lockMode = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun quality(): DecoderQuality {
|
|
||||||
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
|
|
||||||
return DecoderQuality(
|
|
||||||
syncHitRate = hitRate,
|
|
||||||
predictedLineBursts = predictedLineBursts,
|
|
||||||
maxPredictedStreak = maxPredictedStreak,
|
|
||||||
timingErrorSamples = timingErrorSamples
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetQuality() {
|
|
||||||
syncChecks = 0
|
|
||||||
syncHits = 0
|
|
||||||
predictedLineBursts = 0
|
|
||||||
predictedStreak = 0
|
|
||||||
maxPredictedStreak = 0
|
|
||||||
timingErrorSamples = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
||||||
private fun stdDev(a: IntArray, m: Double): Double {
|
private fun stdDev(a: IntArray, m: Double): Double {
|
||||||
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
||||||
@@ -607,29 +426,6 @@ internal class DecoderEngine(
|
|||||||
if (finish) drawLines(0xff000000.toInt(), 10)
|
if (finish) drawLines(0xff000000.toInt(), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decodePredictedLine(): Boolean {
|
|
||||||
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
|
|
||||||
// real sync to reduce visible vertical compression on weak/noisy signals.
|
|
||||||
if (predictedStreak >= maxConsecutivePredictedLines) return false
|
|
||||||
val expectedSync = lastSync + curLineSamples
|
|
||||||
timingErrorSamples = sample - expectedSync
|
|
||||||
val decoded = currentMode.decodeScanLine(
|
|
||||||
pixelBuffer,
|
|
||||||
scratch,
|
|
||||||
scanLineBuffer,
|
|
||||||
scopeBuffer.width,
|
|
||||||
lastSync,
|
|
||||||
curLineSamples,
|
|
||||||
lastOffset
|
|
||||||
)
|
|
||||||
copyLines(decoded)
|
|
||||||
lastSync = expectedSync
|
|
||||||
predictedLineBursts++
|
|
||||||
predictedStreak++
|
|
||||||
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun drawLines(color: Int, count: Int) {
|
private fun drawLines(color: Int, count: Int) {
|
||||||
repeat(count) {
|
repeat(count) {
|
||||||
scopeBuffer.pixels.fill(
|
scopeBuffer.pixels.fill(
|
||||||
@@ -746,7 +542,6 @@ internal class DecoderEngine(
|
|||||||
lineLen: IntArray,
|
lineLen: IntArray,
|
||||||
latest: Int
|
latest: Int
|
||||||
): Boolean {
|
): Boolean {
|
||||||
predictedStreak = 0
|
|
||||||
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
||||||
syncPulses[syncPulses.size - 1] = latest
|
syncPulses[syncPulses.size - 1] = latest
|
||||||
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
val line1 = tle[1]
|
val line1 = tle[1]
|
||||||
val line2 = tle[2]
|
val line2 = tle[2]
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = tle[0].trim().removePrefix("0 "),
|
name = tle[0].trim(),
|
||||||
epoch = line1.substring(18, 32).toDouble(),
|
epoch = line1.substring(18, 32).toDouble(),
|
||||||
meanmo = line2.substring(52, 63).toDouble(),
|
meanmo = line2.substring(52, 63).toDouble(),
|
||||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||||
|
|||||||
-141
@@ -1,141 +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.
|
|
||||||
*/
|
|
||||||
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, this computes the other:
|
|
||||||
*
|
|
||||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
|
||||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
|
||||||
*/
|
|
||||||
object DopplerFrequencyCalculator {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
|
||||||
* Returns null if the transponder is not a linear passband type.
|
|
||||||
*/
|
|
||||||
fun computeUplinkFromDownlink(
|
|
||||||
downlinkHz: Long,
|
|
||||||
transponder: SatRadio,
|
|
||||||
orbitalPos: OrbitalPos
|
|
||||||
): Long? {
|
|
||||||
if (!isLinearTransponder(transponder)) return null
|
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
transponder: SatRadio,
|
|
||||||
orbitalPos: OrbitalPos,
|
|
||||||
offsetHz: Long
|
|
||||||
): Long? {
|
|
||||||
if (!isLinearTransponder(transponder)) return null
|
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
|
||||||
* Returns null if the transponder is not a linear passband type.
|
|
||||||
*/
|
|
||||||
fun computeDownlinkFromUplink(
|
|
||||||
uplinkHz: Long,
|
|
||||||
transponder: SatRadio,
|
|
||||||
orbitalPos: OrbitalPos
|
|
||||||
): Long? {
|
|
||||||
if (!isLinearTransponder(transponder)) return null
|
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
|
||||||
* with an offset applied to the downlink (in Hz).
|
|
||||||
* Returns null if the transponder is not a linear passband type.
|
|
||||||
*/
|
|
||||||
fun computeDownlinkFromUplinkWithOffset(
|
|
||||||
uplinkHz: Long,
|
|
||||||
transponder: SatRadio,
|
|
||||||
orbitalPos: OrbitalPos,
|
|
||||||
offsetHz: Long
|
|
||||||
): Long? {
|
|
||||||
if (!isLinearTransponder(transponder)) return null
|
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if this transponder supports linear passband mapping. */
|
|
||||||
fun isLinearTransponder(transponder: SatRadio): Boolean {
|
|
||||||
val upLow = transponder.uplinkLow
|
|
||||||
val upHigh = transponder.uplinkHigh
|
|
||||||
val downLow = transponder.downlinkLow
|
|
||||||
val downHigh = transponder.downlinkHigh
|
|
||||||
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") || info.contains(" lin") || info.startsWith("lin")
|
|
||||||
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) ||
|
|
||||||
(hasLinearName && hasLinearMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes duplicate transponder entries that describe the same physical
|
|
||||||
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
|
||||||
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
|
||||||
* "CW Transponder" and "SSB Transponder").
|
|
||||||
*
|
|
||||||
* Entries sharing the same uplink/downlink frequency range are considered
|
|
||||||
* the same transponder. The non-CW entry is preferred because its invert
|
|
||||||
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
|
||||||
*/
|
|
||||||
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
|
||||||
return radios.groupBy { radio ->
|
|
||||||
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
|
||||||
}.values.map { group ->
|
|
||||||
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,21 +41,6 @@ fun Double.round(decimals: Int): Double {
|
|||||||
return kotlin.math.round(this * multiplier) / multiplier
|
return kotlin.math.round(this * multiplier) / multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String.aprsPasscode(): Int {
|
|
||||||
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
|
||||||
var hash = 0x73E2
|
|
||||||
var i = 0
|
|
||||||
while (i < callsign.length) {
|
|
||||||
hash = hash xor (callsign[i].code shl 8)
|
|
||||||
i++
|
|
||||||
if (i < callsign.length) {
|
|
||||||
hash = hash xor callsign[i].code
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hash and 0x7FFF
|
|
||||||
}
|
|
||||||
|
|
||||||
//fun String.getHash(type: String = "SHA-256"): String {
|
//fun String.getHash(type: String = "SHA-256"): String {
|
||||||
// val hexChars = "0123456789ABCDEF"
|
// val hexChars = "0123456789ABCDEF"
|
||||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -240,10 +239,4 @@ class DataParserTest {
|
|||||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `check APRS passcode calculation`() {
|
|
||||||
assert("M7LNB".aprsPasscode() == 12443)
|
|
||||||
assert("N0CALL".aprsPasscode() == 13023)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
-273
@@ -1,273 +0,0 @@
|
|||||||
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.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 {
|
|
||||||
|
|
||||||
private fun linearTransponder(
|
|
||||||
uuid: String = "linear",
|
|
||||||
upLow: Long = 145_000_000L,
|
|
||||||
upHigh: Long = 145_500_000L,
|
|
||||||
downLow: Long = 435_000_000L,
|
|
||||||
downHigh: Long? = 435_500_000L,
|
|
||||||
inverted: Boolean = false,
|
|
||||||
info: String = "Linear Transponder",
|
|
||||||
downlinkMode: String? = "USB",
|
|
||||||
uplinkMode: String? = "LSB"
|
|
||||||
) = SatRadio(
|
|
||||||
uuid = uuid, info = info, isAlive = true,
|
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
|
||||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
|
||||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun fmTransponder() = SatRadio(
|
|
||||||
uuid = "fm", info = "FM Repeater", isAlive = true,
|
|
||||||
downlinkLow = 435_600_000L, downlinkHigh = null,
|
|
||||||
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
|
|
||||||
uplinkMode = "FM", isInverted = false, catnum = 99999
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
|
|
||||||
this.distanceRate = distanceRateKmS
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isLinearTransponder_returnsTrueForLinear() {
|
|
||||||
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isLinearTransponder_returnsFalseForFM() {
|
|
||||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
|
|
||||||
val xpdr = linearTransponder(downHigh = null)
|
|
||||||
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_returnsTrueForAbbreviatedLinName() {
|
|
||||||
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
|
||||||
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
|
||||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
|
||||||
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
|
||||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
|
||||||
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
|
||||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
|
||||||
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
|
||||||
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
|
||||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
|
||||||
// "Downlink" contains "lin" but is not a linear-transponder name
|
|
||||||
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
|
||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
|
||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
|
||||||
// AO-7's Mode A: same range, SSB and CW entries
|
|
||||||
val ssb = linearTransponder(
|
|
||||||
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
|
||||||
downlinkMode = "USB", uplinkMode = "USB"
|
|
||||||
)
|
|
||||||
val cw = linearTransponder(
|
|
||||||
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
|
||||||
downlinkMode = "CW", uplinkMode = "CW"
|
|
||||||
)
|
|
||||||
val modeB = linearTransponder(
|
|
||||||
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
|
||||||
upLow = 432_125_000L, upHigh = 432_175_000L,
|
|
||||||
downLow = 145_925_000L, downHigh = 145_975_000L,
|
|
||||||
downlinkMode = "USB", uplinkMode = "LSB"
|
|
||||||
)
|
|
||||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
|
||||||
assertEquals(2, result.size)
|
|
||||||
// SSB entry should be preferred over CW (same range)
|
|
||||||
assertEquals("ssb-uuid", result[0].uuid)
|
|
||||||
assertEquals("modeb-uuid", result[1].uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deduplicateTransponders_prefersNonCwEntry() {
|
|
||||||
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
|
||||||
val cw = linearTransponder(
|
|
||||||
uuid = "cw-uuid", info = "U/V CW Transponder",
|
|
||||||
downlinkMode = "CW", uplinkMode = "CW",
|
|
||||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
|
||||||
downLow = 145_855_000L, downHigh = 145_875_000L
|
|
||||||
)
|
|
||||||
val ssb = linearTransponder(
|
|
||||||
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
|
||||||
downlinkMode = "USB", uplinkMode = "LSB",
|
|
||||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
|
||||||
downLow = 145_855_000L, downHigh = 145_875_000L,
|
|
||||||
inverted = true
|
|
||||||
)
|
|
||||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
|
||||||
assertEquals(1, result.size)
|
|
||||||
assertEquals("ssb-uuid", result[0].uuid)
|
|
||||||
// Verify the correct invert flag is preserved
|
|
||||||
assertTrue(result[0].isInverted)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deduplicateTransponders_preservesUniqueEntries() {
|
|
||||||
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
|
||||||
downLow = 435_000_000L, downHigh = 435_500_000L)
|
|
||||||
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
|
||||||
downLow = 145_000_000L, downHigh = 145_500_000L)
|
|
||||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
|
||||||
assertEquals(2, result.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
|
||||||
val xpdr = linearTransponder()
|
|
||||||
val orbitalPos = pos(0.0)
|
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
|
||||||
assertNotNull(uplink)
|
|
||||||
assertEquals(145_200_000L, uplink)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun computeDownlinkFromUplink_linear_noDoppler() {
|
|
||||||
val xpdr = linearTransponder()
|
|
||||||
val orbitalPos = pos(0.0)
|
|
||||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
|
||||||
assertNotNull(downlink)
|
|
||||||
assertEquals(435_200_000L, downlink)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
|
||||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
|
||||||
val xpdr = linearTransponder()
|
|
||||||
val orbitalPos = pos(7.0)
|
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
|
||||||
assertNotNull(uplink)
|
|
||||||
assertTrue(uplink!! > 145_200_000L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
|
||||||
val orbitalPos = pos()
|
|
||||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
|
||||||
assertNull(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
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)
|
|
||||||
assertEquals(145_300_000L, uplink)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun computeUplinkFromDownlink_roundTrip() {
|
|
||||||
val xpdr = linearTransponder()
|
|
||||||
val orbitalPos = pos(3.5)
|
|
||||||
val originalDownlink = 435_250_000L
|
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
|
|
||||||
assertNotNull(uplink)
|
|
||||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
|
||||||
assertNotNull(roundTripDownlink)
|
|
||||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
|
||||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-79
@@ -1,79 +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.predict
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import java.time.Instant
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
class CelestialComputerTest {
|
|
||||||
|
|
||||||
private data class RiseSetCase(
|
|
||||||
val name: String,
|
|
||||||
val observer: GeoPos,
|
|
||||||
val startIso: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
|
||||||
val cases = listOf(
|
|
||||||
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
|
||||||
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
|
||||||
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
|
||||||
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
|
||||||
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
|
||||||
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
|
||||||
)
|
|
||||||
|
|
||||||
cases.forEach { testCase ->
|
|
||||||
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
|
||||||
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
|
||||||
|
|
||||||
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
|
||||||
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
|
||||||
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
|
||||||
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
|
||||||
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
|
||||||
|
|
||||||
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
|
||||||
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
|
||||||
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
|
||||||
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
|
||||||
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
|
||||||
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
|
||||||
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
|
||||||
|
|
||||||
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
|
||||||
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
|
||||||
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+37
-142
@@ -27,43 +27,30 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
@@ -76,14 +63,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.Velocity
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
@@ -150,7 +134,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -219,11 +203,6 @@ 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
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -239,13 +218,8 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IconCard(
|
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||||
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||||
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
|
||||||
) {
|
|
||||||
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
|
||||||
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||||
}
|
}
|
||||||
@@ -301,124 +275,58 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoDialog(
|
fun SharedDialog(
|
||||||
title: String,
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onAccept: () -> Unit,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
) {
|
||||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = padding, top = padding, end = padding)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
|
||||||
}
|
|
||||||
content()
|
content()
|
||||||
}
|
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ConfirmDialog(
|
|
||||||
title: String,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
onAccept: () -> Unit,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
DialogShell(onDismissRequest = onCancel) { padding ->
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = padding, top = padding, end = padding)
|
|
||||||
) {
|
|
||||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
Text(
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
text = title,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = padding)
|
|
||||||
)
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
}
|
}
|
||||||
content()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
||||||
InfoDialog(
|
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
|
||||||
title = stringResource(R.string.pass_whatsnew_title),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
onAccept = onDismiss
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.pass_whatsnew_message),
|
text = text,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
modifier = Modifier.padding(horizontal = it)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DialogShell(
|
private fun DialogShell(
|
||||||
|
title: String,
|
||||||
|
titleFontSize: Int,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
content: @Composable (padding: Dp) -> Unit
|
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
Dialog(onDismissRequest = onDismissRequest) {
|
||||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
ElevatedCard {
|
||||||
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
Column(
|
||||||
val maxSheetHeight = containerHeight - statusBarHeight
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
val stopSheetFling = remember {
|
verticalArrangement = Arrangement.spacedBy(padding)
|
||||||
object : NestedScrollConnection {
|
) {
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
Text(
|
||||||
return available
|
text = title,
|
||||||
|
fontSize = titleFontSize.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
||||||
|
)
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPostScroll(
|
|
||||||
consumed: Offset,
|
|
||||||
available: Offset,
|
|
||||||
source: NestedScrollSource
|
|
||||||
): Offset = Offset.Zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
sheetState = sheetState,
|
|
||||||
dragHandle = null,
|
|
||||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
|
||||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(padding),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = maxSheetHeight)
|
|
||||||
.nestedScroll(stopSheetFling)
|
|
||||||
) {
|
|
||||||
content(padding)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,26 +391,13 @@ fun TopBar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun elevationColor(elevation: Double): Color {
|
fun elevationColor(elevation: Double): Color {
|
||||||
val thresholds = LocalElevationThresholds.current
|
|
||||||
return when {
|
return when {
|
||||||
elevation < thresholds.low -> ElevationLowColor // soft red for low elevation
|
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
|
||||||
elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
else -> ElevationHighColor // soft green for high elevation
|
else -> Color(0xFF66BB6A) // soft green for high elevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User-configurable elevation highlight thresholds (in degrees). */
|
|
||||||
data class ElevationThresholds(val low: Double = 15.0, val high: Double = 45.0)
|
|
||||||
|
|
||||||
/** Provided at the app root from settings; defaults keep the original 15°/45° behavior. */
|
|
||||||
val LocalElevationThresholds = compositionLocalOf { ElevationThresholds() }
|
|
||||||
|
|
||||||
/** Soft red used for elevations below the low threshold. */
|
|
||||||
val ElevationLowColor = Color(0xFFEF5350)
|
|
||||||
|
|
||||||
/** Soft green used for elevations above the high threshold. */
|
|
||||||
val ElevationHighColor = Color(0xFF66BB6A)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OutlinedText(
|
fun OutlinedText(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
+4
-8
@@ -79,10 +79,6 @@ private val lightScheme = lightColorScheme(
|
|||||||
onSecondary = Color(0xFFFFFFFF),
|
onSecondary = Color(0xFFFFFFFF),
|
||||||
secondaryContainer = Color(0xFFF1E1BB),
|
secondaryContainer = Color(0xFFF1E1BB),
|
||||||
onSecondaryContainer = Color(0xFF221B04),
|
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),
|
background = Color(0xFFFFF8F0),
|
||||||
onBackground = Color(0xFF1E1B13),
|
onBackground = Color(0xFF1E1B13),
|
||||||
surface = Color(0xFFFFF8F0),
|
surface = Color(0xFFFFF8F0),
|
||||||
@@ -105,10 +101,10 @@ private val darkScheme = darkColorScheme(
|
|||||||
onSecondary = Color(0xFF000000),
|
onSecondary = Color(0xFF000000),
|
||||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||||
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
// tertiary = Color(0xFF121212),
|
||||||
onTertiary = Color(0xFF000000),
|
// onTertiary = Color(0xFF121212),
|
||||||
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
// tertiaryContainer = Color(0xFF121212),
|
||||||
onTertiaryContainer = Color(0xFF000000),
|
// onTertiaryContainer = Color(0xFF121212),
|
||||||
background = Color(0xFF121212),
|
background = Color(0xFF121212),
|
||||||
onBackground = Color(0xFFE0E0E0),
|
onBackground = Color(0xFFE0E0E0),
|
||||||
surface = Color(0xFF202020), // card background
|
surface = Color(0xFF202020), // card background
|
||||||
|
|||||||
+2
-2
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
|
|||||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
|
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
|
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|||||||
@@ -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="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
|
||||||
</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="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
||||||
|
</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="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,31 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="128"
|
|
||||||
android:viewportHeight="128">
|
|
||||||
<path
|
|
||||||
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
</vector>
|
|
||||||
File renamed without changes.
@@ -0,0 +1,9 @@
|
|||||||
|
<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,16 +13,12 @@
|
|||||||
<string name="nav_prefs">Ajustes</string>
|
<string name="nav_prefs">Ajustes</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Modos: %s</string>
|
<string name="sat_type_hint">Tipo: %s</string>
|
||||||
<string name="sat_type_title">Seleccionar modos</string>
|
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
||||||
<string name="sat_search_hint">Id - Nombre</string>
|
<string name="sat_search_hint">Id - Nombre</string>
|
||||||
<string name="sat_search_clear">Vaciar</string>
|
<string name="sat_search_clear">Vaciar</string>
|
||||||
<string name="sat_clear_all">Vaciar todo</string>
|
<string name="sat_clear_all">Vaciar todo</string>
|
||||||
<string name="sat_select_all">Seleccionar 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">
|
<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>
|
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>
|
<string name="sat_warning_title">Alarma\!</string>
|
||||||
@@ -30,22 +26,19 @@
|
|||||||
Esta aplicación incluye más de 9000 satélites.
|
Esta aplicación incluye más de 9000 satélites.
|
||||||
No tiene sentido rastrearlos todos a la vez.
|
No tiene sentido rastrearlos todos a la vez.
|
||||||
\n\nIntenta siempre limitar la lista a los que te
|
\n\nIntenta siempre limitar la lista a los que te
|
||||||
interesen mediante la búsqueda y el selector de modos.</string>
|
interesen mediante la búsqueda y el selector de tipos.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
<string name="pass_filter_title">Filtrar pases</string>
|
||||||
<string name="pass_filter_elev">Elevación mínima</string>
|
<string name="pass_filter_elev">Elevación mínima</string>
|
||||||
<string name="pass_filter_hours">Tiempo por delante</string>
|
<string name="pass_filter_hours">Horas por delante</string>
|
||||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
<string name="pass_modes_title">Tipo de modulación</string>
|
||||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
|
||||||
<string name="pass_modes_title">Seleccionar modos</string>
|
|
||||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
||||||
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
||||||
<string name="pass_welcome_title">Bienvenido a Look4Sat\!</string>
|
<string name="pass_welcome_title">Bienvenido a Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
||||||
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
||||||
@@ -100,11 +93,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<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_qth_error">Ubicación QTH no válida</string>
|
||||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
||||||
<string name="prefs_station_title">Ubicación de la estación</string>
|
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
|
||||||
<string name="prefs_station_lat_text">Latitud de estación</string>
|
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
||||||
<string name="prefs_station_lon_text">Longitud de estación</string>
|
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
||||||
<string name="prefs_locator_title">Localizador QTH</string>
|
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
||||||
<string name="prefs_locator_text">Posición por localizador</string>
|
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Datos satelitales</string>
|
<string name="prefs_data_title">Datos satelitales</string>
|
||||||
<string name="prefs_data_entries">Satélites: %s</string>
|
<string name="prefs_data_entries">Satélites: %s</string>
|
||||||
@@ -114,60 +107,38 @@
|
|||||||
<string name="prefs_data_clear">Vaciar</string>
|
<string name="prefs_data_clear">Vaciar</string>
|
||||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</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_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">Fuentes personalizadas</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</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">Salida de datos</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Red</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">Control de radio</string>
|
<string name="prefs_net_title">Salida de datos de red</string>
|
||||||
<string name="rc_settings_title">Control CAT</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</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_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||||
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</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_net_frequency_format_hint">Formato de datos</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Salida Bluetooth</string>
|
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Activar salida de rotación</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_device_hint">Id dispositivo</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
||||||
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</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_device_hint">Id dispositivo</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
||||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</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_net_perm_error">Revisa los permisos de red</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Otros</string>
|
<string name="prefs_other_title">Otros ajustes</string>
|
||||||
<string name="prefs_other_switch_utc">Hora UTC</string>
|
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||||
<string name="prefs_other_switch_sweep">Animación radar</string>
|
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Sensores</string>
|
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</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 < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Media %1$d-%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">Alta > %1$d°</string>
|
|
||||||
|
|
||||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
||||||
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
||||||
|
|||||||
@@ -13,38 +13,31 @@
|
|||||||
<string name="nav_prefs">Настройки</string>
|
<string name="nav_prefs">Настройки</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Режимы: %s</string>
|
<string name="sat_type_hint">Тип: %s</string>
|
||||||
<string name="sat_type_title">Выберите режимы</string>
|
<string name="sat_type_title">Выберите тип спутника</string>
|
||||||
<string name="sat_search_hint">Id - Название</string>
|
<string name="sat_search_hint">Id - Название</string>
|
||||||
<string name="sat_search_clear">Очистить</string>
|
<string name="sat_search_clear">Очистить</string>
|
||||||
<string name="sat_clear_all">Очистить</string>
|
<string name="sat_clear_all">Очистить</string>
|
||||||
<string name="sat_select_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_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||||
<string name="sat_warning_title">Внимание\!</string>
|
<string name="sat_warning_title">Внимание\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
В этом приложении перечислено более 9000 спутников.
|
В этом приложении перечислено более 9000 спутников.
|
||||||
Отслеживать их все одновременно бессмысленно.
|
Отслеживать их все одновременно бессмысленно.
|
||||||
\n\nВсегда старайтесь сузить список до тех,
|
\n\nВсегда старайтесь сузить список до тех,
|
||||||
которые вас интересуют, используя поиск и выбор режимов.</string>
|
которые вас интересуют, используя поиск и селектор типов.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||||
<string name="pass_filter_hours">Период расчёта</string>
|
<string name="pass_filter_hours">Количество часов</string>
|
||||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
<string name="pass_modes_title">Выберите тип модуляции</string>
|
||||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
|
||||||
<string name="pass_modes_title">Выберите режимы</string>
|
|
||||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||||
<string name="pass_altitude">Высота: %d км</string>
|
<string name="pass_altitude">Высота: %d км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
||||||
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
||||||
<string name="pass_welcome_title">Добро пожаловать в Look4Sat\!</string>
|
<string name="pass_welcome_title">Добро пожаловать в Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
||||||
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
||||||
@@ -99,11 +92,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
||||||
<string name="prefs_station_title">Положение станции</string>
|
<string name="prefs_station_title">Настройки местоположения</string>
|
||||||
<string name="prefs_station_lat_text">Широта станции</string>
|
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
|
||||||
<string name="prefs_station_lon_text">Долгота станции</string>
|
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
|
||||||
<string name="prefs_locator_title">Локатор QTH</string>
|
<string name="prefs_locator_title">Настройки локатора QTH</string>
|
||||||
<string name="prefs_locator_text">Позиция по локатору</string>
|
<string name="prefs_locator_text">Введите позицию через локатор</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Данные спутников</string>
|
<string name="prefs_data_title">Данные спутников</string>
|
||||||
<string name="prefs_data_entries">Спутников: %s</string>
|
<string name="prefs_data_entries">Спутников: %s</string>
|
||||||
@@ -113,33 +106,16 @@
|
|||||||
<string name="prefs_data_clear">Очистить</string>
|
<string name="prefs_data_clear">Очистить</string>
|
||||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
||||||
<string name="prefs_data_update_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_title">Свои источники данных</string>
|
||||||
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_sat_title">Данные спутников</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_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_net_output">Сеть</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">Управление радио</string>
|
<string name="prefs_net_title">Вывод сетевых данных</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_switch">Включить вывод ротатора</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||||
@@ -147,27 +123,21 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</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_title">Вывод данных Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</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_rotator_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_frequency_switch">Включить вывод частоты</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_frequency_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Прочее</string>
|
<string name="prefs_other_title">Другие настройки</string>
|
||||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||||
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Сенсоры</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">Низко < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Средне %1$d-%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">Высоко > %1$d°</string>
|
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
||||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
||||||
|
|||||||
@@ -13,39 +13,32 @@
|
|||||||
<string name="nav_prefs">පසුතල</string>
|
<string name="nav_prefs">පසුතල</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">මාදිලි: %s</string>
|
<string name="sat_type_hint">වර්ගය: %s</string>
|
||||||
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
||||||
<string name="sat_search_hint">Id - නම</string>
|
<string name="sat_search_hint">Id - නම</string>
|
||||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||||
<string name="sat_select_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 name="sat_empty_list_message">
|
||||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||||
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
||||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
||||||
<string name="pass_filter_hours">ඉදිරි කාලය</string>
|
<string name="pass_filter_hours">ඉදිරි පැය</string>
|
||||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
||||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
|
||||||
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
|
|
||||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
||||||
<string name="pass_welcome_title">Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
<string name="pass_welcome_title">Look4Sat-CNMap වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
||||||
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
||||||
@@ -100,11 +93,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
||||||
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
||||||
<string name="prefs_station_title">පොළ පිහිටීම</string>
|
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
|
||||||
<string name="prefs_station_lat_text">පොළ අක්ෂාංශය</string>
|
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්ෂාංශය සකසන්න</string>
|
||||||
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
|
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
|
||||||
<string name="prefs_locator_title">QTH locator</string>
|
<string name="prefs_locator_title">QTH locator පසුතල</string>
|
||||||
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
|
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
||||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
||||||
@@ -114,60 +107,38 @@
|
|||||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
||||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
||||||
<string name="prefs_data_update_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_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්ර ප්රතිස්ථාපනය කරන්න</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_sat_title">චන්ද්රිකා දත්ත</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_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">දත්ත ප්රතිදානය</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">ජාලය</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</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_title">ජාල ප්රතිදානය</string>
|
||||||
<string name="prefs_net_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</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_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
|
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</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_title">Bluetooth දත්ත ප්රතිදානය</string>
|
||||||
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</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_device_hint">උපාංග id</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
|
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</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_device_hint">උපාංග id</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
|
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">වෙනත්</string>
|
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||||
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||||
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||||
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
|
||||||
|
|
||||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">මධ්ය %1$d-%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">ඉහළ > %1$d°</string>
|
|
||||||
|
|
||||||
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
||||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Commmon -->
|
<!-- Commmon -->
|
||||||
<string name="app_name" translatable="false">Look4Sat</string>
|
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
||||||
<string name="btn_accept">Kabul et</string>
|
<string name="btn_accept">Kabul et</string>
|
||||||
<string name="btn_cancel">İptal</string>
|
<string name="btn_cancel">İptal</string>
|
||||||
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
||||||
@@ -14,33 +14,26 @@
|
|||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Modlar: %s</string>
|
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||||
<string name="sat_type_title">Modları seçin</string>
|
<string name="sat_type_title">Uydu türü seçin</string>
|
||||||
<string name="sat_search_hint">Id - Ad</string>
|
<string name="sat_search_hint">Id - Ad</string>
|
||||||
<string name="sat_search_clear">Temizle</string>
|
<string name="sat_search_clear">Temizle</string>
|
||||||
<string name="sat_clear_all">Tümünü 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_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_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_title">Uyarı\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
||||||
Hepsini aynı anda takip etmek anlamsızdır.
|
Hepsini aynı anda takip etmek anlamsızdır.
|
||||||
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
||||||
daraltmak için arama ve mod seçiciyi kullanın.</string>
|
daraltmak için arama ve tür seçiciyi kullanın.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||||
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
||||||
<string name="pass_filter_hours">Gösterilecek zaman</string>
|
<string name="pass_filter_hours">Gösterilecek saat aralığı</string>
|
||||||
<string name="pass_filter_aos_time">AOS aralığı</string>
|
|
||||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Modları seçin</string>
|
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
@@ -49,11 +42,11 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
||||||
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
||||||
<string name="pass_welcome_title">Look4Sat\'a hoş geldiniz\!</string>
|
<string name="pass_welcome_title">Look4Sat-CNMap\'a hoş geldiniz\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
||||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
\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_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||||
@@ -91,7 +84,8 @@
|
|||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Önceki</string>
|
<string name="map_prev">Önceki</string>
|
||||||
<string name="map_next">Sonraki</string>
|
<string name="map_next">Sonraki</string>
|
||||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||||
|
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||||
<string name="map_azimuth">Azimut: %.1f°</string>
|
<string name="map_azimuth">Azimut: %.1f°</string>
|
||||||
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
<string name="map_altitude">İrtifa: %.0f km</string>
|
<string name="map_altitude">İrtifa: %.0f km</string>
|
||||||
@@ -107,12 +101,12 @@
|
|||||||
<string name="map_visible">Görünür</string>
|
<string name="map_visible">Görünür</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap 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_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
||||||
<string name="prefs_donate_title">Bağış yap</string>
|
<string name="prefs_donate_title">Bağış yap</string>
|
||||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
||||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
@@ -131,11 +125,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<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_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_loc_success">Konum başarıyla güncellendi</string>
|
||||||
<string name="prefs_station_title">İstasyon konumu</string>
|
<string name="prefs_station_title">İstasyon konumu ayarları</string>
|
||||||
<string name="prefs_station_lat_text">İstasyon enlemi</string>
|
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
|
||||||
<string name="prefs_station_lon_text">İstasyon boylamı</string>
|
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
|
||||||
<string name="prefs_locator_title">QTH konumlandırıcı</string>
|
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
|
||||||
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
|
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Uydu verisi</string>
|
<string name="prefs_data_title">Uydu verisi</string>
|
||||||
<string name="prefs_data_entries">Uydular: %s</string>
|
<string name="prefs_data_entries">Uydular: %s</string>
|
||||||
@@ -145,33 +139,28 @@
|
|||||||
<string name="prefs_data_clear">Temizle</string>
|
<string name="prefs_data_clear">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</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_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 kaynaklar</string>
|
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||||
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
|
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||||
<string name="prefs_data_sources_sat_title">Uydu verileri</string>
|
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</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_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Veri çıkışı</string>
|
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||||
<string name="prefs_net_output">Ağ</string>
|
<string name="prefs_net_output">Ağ</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
||||||
<string name="rc_settings_title">CAT kontrolü</string>
|
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
|
||||||
<string name="rc_radio_model">Radyo Modeli</string>
|
<string name="rc_radio_model">Radyo Modeli</string>
|
||||||
<string name="rc_tx_device_hint">TX BT adresi</string>
|
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
|
||||||
<string name="rc_rx_device_hint">RX BT adresi</string>
|
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
|
||||||
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
||||||
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
||||||
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
|
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Ağ çıkışı</string>
|
<string name="prefs_net_title">Ağ veri çıkışı</string>
|
||||||
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</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_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
||||||
@@ -179,27 +168,21 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth çıkışı</string>
|
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
|
||||||
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</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_device_hint">Cihaz kimliği</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Biçim</string>
|
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
|
||||||
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</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_device_hint">Cihaz kimliği</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Biçim</string>
|
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</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_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Diğer</string>
|
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||||
<string name="prefs_other_switch_utc">UTC saati</string>
|
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||||
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sensors">Sensör</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">Gece filtresi</string>
|
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">Yüksek > %1$d°</string>
|
|
||||||
|
|
||||||
<string name="prefs_outro_title">Teşekkürler</string>
|
<string name="prefs_outro_title">Teşekkürler</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
@@ -13,16 +13,12 @@
|
|||||||
<string name="nav_prefs">Налаштування</string>
|
<string name="nav_prefs">Налаштування</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Режими: %s</string>
|
<string name="sat_type_hint">Тип: %s</string>
|
||||||
<string name="sat_type_title">Виберіть режими</string>
|
<string name="sat_type_title">Виберіть тип супутника</string>
|
||||||
<string name="sat_search_hint">Id - Назва</string>
|
<string name="sat_search_hint">Id - Назва</string>
|
||||||
<string name="sat_search_clear">Очистити</string>
|
<string name="sat_search_clear">Очистити</string>
|
||||||
<string name="sat_clear_all">Очистити всі</string>
|
<string name="sat_clear_all">Очистити всі</string>
|
||||||
<string name="sat_select_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 name="sat_empty_list_message">
|
||||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||||
<string name="sat_warning_title">Увага\!</string>
|
<string name="sat_warning_title">Увага\!</string>
|
||||||
@@ -30,22 +26,19 @@
|
|||||||
У цьому додатку перелічено понад 9000 супутників.
|
У цьому додатку перелічено понад 9000 супутників.
|
||||||
Немає сенсу відстежувати їх усі одночасно.
|
Немає сенсу відстежувати їх усі одночасно.
|
||||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||||
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||||
<string name="pass_filter_elev">Мінімальне піднесень</string>
|
<string name="pass_filter_elev">Мінімальне піднесення</string>
|
||||||
<string name="pass_filter_hours">Час вперед</string>
|
<string name="pass_filter_hours">Кількість годин</string>
|
||||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
||||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
|
||||||
<string name="pass_modes_title">Виберіть режими</string>
|
|
||||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||||
<string name="pass_altitude">Висота: %.0f км</string>
|
<string name="pass_altitude">Висота: %.0f км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
||||||
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
||||||
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat\!</string>
|
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
||||||
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
||||||
@@ -73,7 +66,7 @@
|
|||||||
<string name="map_prev">Попередній</string>
|
<string name="map_prev">Попередній</string>
|
||||||
<string name="map_next">Наступний</string>
|
<string name="map_next">Наступний</string>
|
||||||
<string name="map_azimuth">Азимут: %.1f°</string>
|
<string name="map_azimuth">Азимут: %.1f°</string>
|
||||||
<string name="map_elevation">Піднесень: %.1f°</string>
|
<string name="map_elevation">Піднесення: %.1f°</string>
|
||||||
<string name="map_altitude">Висота: %.0f км</string>
|
<string name="map_altitude">Висота: %.0f км</string>
|
||||||
<string name="map_distance">Дистанція: %.0f км</string>
|
<string name="map_distance">Дистанція: %.0f км</string>
|
||||||
<string name="map_latitude">Широта: %.1f°</string>
|
<string name="map_latitude">Широта: %.1f°</string>
|
||||||
@@ -100,11 +93,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||||
<string name="prefs_station_title">Позиція станції</string>
|
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
||||||
<string name="prefs_station_lat_text">Широта станції</string>
|
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
||||||
<string name="prefs_station_lon_text">Довгота станції</string>
|
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
||||||
<string name="prefs_locator_title">Локатор QTH</string>
|
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
||||||
<string name="prefs_locator_text">Позиція за локатором</string>
|
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Супутникові дані</string>
|
<string name="prefs_data_title">Супутникові дані</string>
|
||||||
<string name="prefs_data_entries">Супутників: %s</string>
|
<string name="prefs_data_entries">Супутників: %s</string>
|
||||||
@@ -114,60 +107,38 @@
|
|||||||
<string name="prefs_data_clear">Очистити</string>
|
<string name="prefs_data_clear">Очистити</string>
|
||||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
||||||
<string name="prefs_data_update_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_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_sat_title">Дані супутників</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_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">Data output</string>
|
||||||
<string name="prefs_net_output">Мережа</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">Керування радіо</string>
|
<string name="prefs_net_title">Вивід мережевих даних</string>
|
||||||
<string name="rc_settings_title">CAT керування</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</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_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||||
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</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_title">Вивід даних Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</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_device_hint">Id пристрою</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</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_device_hint">Id пристрою</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Інше</string>
|
<string name="prefs_other_title">Інші налаштування</string>
|
||||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||||
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Сенсори</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">Низько < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Середньо %1$d-%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">Високо > %1$d°</string>
|
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
||||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
||||||
|
|||||||
@@ -12,53 +12,27 @@
|
|||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
<!-- AMSAT Status screen -->
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">AMSAT 卫星状态</string>
|
|
||||||
<string name="amsat_refresh">刷新</string>
|
|
||||||
<string name="amsat_retry">重试</string>
|
|
||||||
<string name="amsat_updated">更新于:</string>
|
|
||||||
<string name="amsat_active">活跃</string>
|
|
||||||
<string name="amsat_tlm">遥测/信标</string>
|
|
||||||
<string name="amsat_not_heard">未听到</string>
|
|
||||||
<string name="amsat_conflict">冲突</string>
|
|
||||||
<string name="amsat_name">名称</string>
|
|
||||||
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
|
||||||
<string name="amsat_no_reports">此时间段无报告</string>
|
|
||||||
<string name="amsat_close">关闭</string>
|
|
||||||
<string name="amsat_status_heard">已听到</string>
|
|
||||||
<string name="amsat_status_tlm_only">仅遥测</string>
|
|
||||||
<string name="amsat_status_not_heard">未听到</string>
|
|
||||||
<string name="amsat_status_crew_active">乘组活跃</string>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">模式:%s</string>
|
<string name="sat_type_hint">类型:%s</string>
|
||||||
<string name="sat_type_title">选择模式</string>
|
<string name="sat_type_title">选择卫星类型</string>
|
||||||
<string name="sat_search_hint">Id - 名称</string>
|
<string name="sat_search_hint">Id - 名称</string>
|
||||||
<string name="sat_search_clear">清空</string>
|
<string name="sat_search_clear">清空</string>
|
||||||
<string name="sat_clear_all">全部清空</string>
|
<string name="sat_clear_all">全部清空</string>
|
||||||
<string name="sat_select_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_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||||
<string name="sat_warning_title">警告\!</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 -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">筛选过境</string>
|
<string name="pass_filter_title">筛选过境</string>
|
||||||
<string name="pass_filter_elev">仰角值</string>
|
<string name="pass_filter_elev">仰角值</string>
|
||||||
<string name="pass_filter_hours">提前时间</string>
|
<string name="pass_filter_hours">小时数</string>
|
||||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
<string name="pass_modes_title">选择调制类型</string>
|
||||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
|
||||||
<string name="pass_modes_title">选择模式</string>
|
|
||||||
<string name="pass_elevation">仰角:%.1f°</string>
|
<string name="pass_elevation">仰角:%.1f°</string>
|
||||||
<string name="pass_altitude">高度:%d km</string>
|
<string name="pass_altitude">高度:%d km</string>
|
||||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
<string name="pass_welcome_title">欢迎来到 Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -79,9 +53,6 @@
|
|||||||
<string name="radar_string_no">否</string>
|
<string name="radar_string_no">否</string>
|
||||||
<string name="radar_string_yes">是</string>
|
<string name="radar_string_yes">是</string>
|
||||||
<string name="radar_visible">日照中</string>
|
<string name="radar_visible">日照中</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>
|
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
@@ -106,7 +77,7 @@
|
|||||||
<string name="prefs_updated_title">更新:%s</string>
|
<string name="prefs_updated_title">更新:%s</string>
|
||||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||||
|
|
||||||
<string name="prefs_loc_title">站点位置</string>
|
<string name="prefs_loc_title">站位</string>
|
||||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
||||||
<string name="prefs_loc_input_title">输入位置</string>
|
<string name="prefs_loc_input_title">输入位置</string>
|
||||||
@@ -114,11 +85,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||||
<string name="prefs_loc_success">位置更新成功</string>
|
<string name="prefs_loc_success">位置更新成功</string>
|
||||||
<string name="prefs_station_title">站点位置</string>
|
<string name="prefs_station_title">站位设置</string>
|
||||||
<string name="prefs_station_lat_text">站点纬度</string>
|
<string name="prefs_station_lat_text">输入站位的纬度</string>
|
||||||
<string name="prefs_station_lon_text">站点经度</string>
|
<string name="prefs_station_lon_text">输入站位的经度</string>
|
||||||
<string name="prefs_locator_title">QTH 定位</string>
|
<string name="prefs_locator_title">QTH 网格</string>
|
||||||
<string name="prefs_locator_text">通过 QTH 设置站点位置</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
<string name="prefs_data_entries">卫星:%s</string>
|
||||||
@@ -128,32 +99,16 @@
|
|||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_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_title">自定义数据源</string>
|
||||||
<string name="prefs_data_sources_restore">恢复默认源</string>
|
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||||
<string name="prefs_data_sources_sat_title">卫星数据</string>
|
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_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_data_output_title">数据输出</string>
|
||||||
<string name="prefs_net_output">网络</string>
|
<string name="prefs_net_output">网络</string>
|
||||||
<string name="prefs_bt_output">蓝牙</string>
|
<string name="prefs_bt_output">蓝牙</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">电台控制</string>
|
<string name="prefs_net_title">网络数据输出</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_switch">启用方位俯仰角输出</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||||
@@ -161,7 +116,7 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_frequency_format_hint">数据格式</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_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_rotator_output_hint">数据格式</string>
|
||||||
@@ -171,19 +126,12 @@
|
|||||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">其他</string>
|
<string name="prefs_other_title">其他设置</string>
|
||||||
<string name="prefs_other_switch_utc">UTC 时间</string>
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
<string name="prefs_other_switch_update">自动更新数据</string>
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||||
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
|
|
||||||
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
|
|
||||||
|
|
||||||
<string name="prefs_highlight_title">仰角高亮</string>
|
|
||||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">高 > %1$d°</string>
|
|
||||||
|
|
||||||
<string name="prefs_outro_title">我要感谢:</string>
|
<string name="prefs_outro_title">我要感谢:</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Commmon -->
|
<!-- Commmon -->
|
||||||
<string name="app_name" translatable="false">Look4Sat</string>
|
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
||||||
<string name="btn_accept">Accept</string>
|
<string name="btn_accept">Accept</string>
|
||||||
<string name="btn_cancel">Cancel</string>
|
<string name="btn_cancel">Cancel</string>
|
||||||
<string name="empty_list_message">No data to show</string>
|
<string name="empty_list_message">No data to show</string>
|
||||||
@@ -12,36 +12,29 @@
|
|||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
<string name="nav_status" translatable="false">AMSAT</string>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Modes: %s</string>
|
<string name="sat_type_hint">Type: %s</string>
|
||||||
<string name="sat_type_title">Select modes</string>
|
<string name="sat_type_title">Select satellite type</string>
|
||||||
<string name="sat_search_hint">Id - Name</string>
|
<string name="sat_search_hint">Id - Name</string>
|
||||||
<string name="sat_search_clear">Clear</string>
|
<string name="sat_search_clear">Clear</string>
|
||||||
<string name="sat_clear_all">Clear all</string>
|
<string name="sat_clear_all">Clear all</string>
|
||||||
<string name="sat_select_all">Select 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_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_title">Warning\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
There are over 9000 satellites listed in this app.
|
There are over 9000 satellites listed in this app.
|
||||||
It makes no sense to track them all at the same time.
|
It makes no sense to track them all at the same time.
|
||||||
\n\nAlways try to narrow down the list to only the ones
|
\n\nAlways try to narrow down the list to only the ones
|
||||||
you\'re interested in via search and the modes selector.</string>
|
you\'re interested in via search and types selector.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filter passes</string>
|
<string name="pass_filter_title">Filter passes</string>
|
||||||
<string name="pass_filter_elev">Minimal elevation</string>
|
<string name="pass_filter_elev">Minimal elevation</string>
|
||||||
<string name="pass_filter_hours">Time ahead</string>
|
<string name="pass_filter_hours">Hours ahead</string>
|
||||||
<string name="pass_filter_aos_time">AOS window</string>
|
|
||||||
<string name="pass_filter_invert_time">Invert AOS window</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Select modes</string>
|
<string name="pass_modes_title">Select modulation type</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Elevation: %.1f°</string>
|
<string name="pass_elevation">Elevation: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
@@ -50,40 +43,16 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Make sure your filters are correct and you have selected satellites</string>
|
Make sure your filters are correct and you have selected satellites</string>
|
||||||
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
||||||
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
<string name="pass_welcome_title">Welcome to Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\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_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
\n* Tweaked Radar track on reposition by atsunatsu (#235)
|
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
\n* Tweaked linear transponder calc by atsunatsu (#236)
|
|
||||||
\n* Added saving per-sat doppler offset by atsunatsu (#237)
|
|
||||||
\n* Added custom freq offset setting to network reporting
|
|
||||||
\n* Added ability to customize data sources via import
|
|
||||||
\n* Fixed star chart in README by PingouinFerreux (#240)
|
|
||||||
</string>
|
</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>
|
|
||||||
<string name="amsat_status_heard">Heard</string>
|
|
||||||
<string name="amsat_status_tlm_only">Telemetry Only</string>
|
|
||||||
<string name="amsat_status_not_heard">Not Heard</string>
|
|
||||||
<string name="amsat_status_crew_active">Crew Active</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
<string name="radar_notify">Notify</string>
|
<string name="radar_notify">Notify</string>
|
||||||
@@ -109,14 +78,12 @@
|
|||||||
<string name="radar_string_no">No</string>
|
<string name="radar_string_no">No</string>
|
||||||
<string name="radar_string_yes">Yes</string>
|
<string name="radar_string_yes">Yes</string>
|
||||||
<string name="radar_visible">Visible</string>
|
<string name="radar_visible">Visible</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>
|
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
<string name="map_next">Next</string>
|
<string name="map_next">Next</string>
|
||||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||||
|
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||||
<string name="map_elevation">Elevation: %.1f°</string>
|
<string name="map_elevation">Elevation: %.1f°</string>
|
||||||
<string name="map_altitude">Altitude: %.0f km</string>
|
<string name="map_altitude">Altitude: %.0f km</string>
|
||||||
@@ -132,12 +99,12 @@
|
|||||||
<string name="map_visible">Visible</string>
|
<string name="map_visible">Visible</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap 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_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
||||||
<string name="prefs_donate_title">Donate</string>
|
<string name="prefs_donate_title">Donate</string>
|
||||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
||||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
@@ -156,11 +123,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
||||||
<string name="prefs_loc_success">Position updated successfully</string>
|
<string name="prefs_loc_success">Position updated successfully</string>
|
||||||
<string name="prefs_station_title">Station position</string>
|
<string name="prefs_station_title">Station position settings</string>
|
||||||
<string name="prefs_station_lat_text">Station latitude</string>
|
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
||||||
<string name="prefs_station_lon_text">Station longitude</string>
|
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
||||||
<string name="prefs_locator_title">QTH locator</string>
|
<string name="prefs_locator_title">QTH locator settings</string>
|
||||||
<string name="prefs_locator_text">Set position via locator</string>
|
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Satellite data</string>
|
<string name="prefs_data_title">Satellite data</string>
|
||||||
<string name="prefs_data_entries">Satellites: %s</string>
|
<string name="prefs_data_entries">Satellites: %s</string>
|
||||||
@@ -170,62 +137,58 @@
|
|||||||
<string name="prefs_data_clear">Clear</string>
|
<string name="prefs_data_clear">Clear</string>
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</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_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">Data sources</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_restore">Restore default sources</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_sat_title">Satellites data</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_title">Transceivers data</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_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_map_title">Map settings</string>
|
||||||
|
<string name="prefs_map_source_osm" translatable="false">OSM</string>
|
||||||
|
<string name="prefs_map_source_tianditu_vector" translatable="false">天地图-矢量</string>
|
||||||
|
<string name="prefs_map_source_tianditu_image" translatable="false">天地图-影像</string>
|
||||||
|
<string name="prefs_map_tianditu_key" translatable="false">天地图密钥</string>
|
||||||
|
<string name="prefs_map_save_key">保存密钥</string>
|
||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radio Control</string>
|
<string name="nav_radiocontrol">Radio Control</string>
|
||||||
<string name="rc_settings_title">CAT control</string>
|
<string name="rc_settings_title">CAT Radio Control</string>
|
||||||
<string name="rc_radio_model">Radio Model</string>
|
<string name="rc_radio_model">Radio Model</string>
|
||||||
<string name="rc_tx_device_hint">TX BT address</string>
|
<string name="rc_tx_device_hint">TX Radio BT Address</string>
|
||||||
<string name="rc_rx_device_hint">RX BT address</string>
|
<string name="rc_rx_device_hint">RX Radio BT Address</string>
|
||||||
<string name="rc_tx_name_hint">TX Radio Name</string>
|
<string name="rc_tx_name_hint">TX Radio Name</string>
|
||||||
<string name="rc_rx_name_hint">RX Radio Name</string>
|
<string name="rc_rx_name_hint">RX Radio Name</string>
|
||||||
<string name="rc_enable_switch">Enable CAT</string>
|
<string name="rc_enable_switch">Enable CAT Control</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Network output</string>
|
<string name="prefs_net_title">Network data output</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation 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_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
|
||||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth output</string>
|
<string name="prefs_bt_title">Bluetooth data output</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation 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_device_hint">Device id</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Format</string>
|
<string name="prefs_bt_rotator_output_hint">Data format</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</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_device_hint">Device id</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Format</string>
|
<string name="prefs_bt_frequency_output_hint">Data format</string>
|
||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</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_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Other</string>
|
<string name="prefs_other_title">Other settings</string>
|
||||||
<string name="prefs_other_switch_utc">UTC time</string>
|
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||||
<string name="prefs_other_switch_update">Auto-update data</string>
|
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors</string>
|
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||||
<string name="prefs_other_switch_night_mode">Night filter</string>
|
<string name="prefs_other_switch_night_mode">Enable red night mode 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 < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">High > %1$d°</string>
|
|
||||||
|
|
||||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
@@ -1,7 +1,2 @@
|
|||||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
* Tweaked Radar track on reposition by atsunatsu (#235)
|
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
* Tweaked linear transponder calc by atsunatsu (#236)
|
|
||||||
* Added saving per-sat doppler offset by atsunatsu (#237)
|
|
||||||
* Added custom freq offset setting to network reporting
|
|
||||||
* Added ability to customize data sources via import
|
|
||||||
* Fixed star chart in README by PingouinFerreux (#240)
|
|
||||||
@@ -1,152 +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.map
|
|
||||||
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.RectF
|
|
||||||
import org.osmdroid.views.MapView
|
|
||||||
import org.osmdroid.views.overlay.Overlay
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom osmdroid overlay that shades the night side of the globe.
|
|
||||||
*
|
|
||||||
* Works entirely in screen-pixel space: for each vertical strip on screen it
|
|
||||||
* asks osmdroid for the geographic coordinate, then tests whether that point is
|
|
||||||
* in the night half-sphere relative to the sub-solar point. Because the
|
|
||||||
* computation happens during draw() the result is always correct regardless
|
|
||||||
* of zoom level or map scroll position — no polygon winding issues possible.
|
|
||||||
*
|
|
||||||
* A point (latRad, lonRad) is in night when the angle to the sub-solar point
|
|
||||||
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
|
||||||
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
|
||||||
*
|
|
||||||
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
|
||||||
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
|
||||||
* evaluations per row, which is imperceptible.
|
|
||||||
*/
|
|
||||||
class MapNightOverlay : Overlay() {
|
|
||||||
|
|
||||||
/** Sub-solar latitude in degrees. */
|
|
||||||
var sunLatDeg: Double = 0.0
|
|
||||||
|
|
||||||
/** Sub-solar longitude in degrees. */
|
|
||||||
var sunLonDeg: Double = 0.0
|
|
||||||
|
|
||||||
private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
color = Color.argb(75, 0, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val rect = RectF()
|
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
|
||||||
if (shadow) return
|
|
||||||
|
|
||||||
val proj = mapView.projection
|
|
||||||
val sunLatRad = Math.toRadians(sunLatDeg)
|
|
||||||
val sunLonRad = Math.toRadians(sunLonDeg)
|
|
||||||
val sinSunLat = sin(sunLatRad)
|
|
||||||
val cosSunLat = cos(sunLatRad)
|
|
||||||
|
|
||||||
val w = mapView.width
|
|
||||||
val h = mapView.height
|
|
||||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
|
||||||
|
|
||||||
// We scan column by column. For each column we determine the longitude,
|
|
||||||
// then find the latitude range that is in night and shade it.
|
|
||||||
// Since longitude is constant along a vertical strip and the day/night
|
|
||||||
// boundary at a given longitude is at most two latitudes, we can do a
|
|
||||||
// scan-line fill efficiently.
|
|
||||||
|
|
||||||
var x = 0
|
|
||||||
while (x < w) {
|
|
||||||
// Get the geographic coordinate at the top and bottom of this column.
|
|
||||||
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
|
||||||
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
|
||||||
|
|
||||||
val lonRad = Math.toRadians(geoTop.longitude)
|
|
||||||
val cosLonDiff = cos(lonRad - sunLonRad)
|
|
||||||
|
|
||||||
// Top pixel geographic lat
|
|
||||||
val latTopRad = Math.toRadians(geoTop.latitude)
|
|
||||||
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
|
||||||
val latBotRad = Math.toRadians(geoBot.latitude)
|
|
||||||
|
|
||||||
// dot(sunVec, pointVec) < 0 → night
|
|
||||||
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
|
||||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
|
||||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
|
||||||
|
|
||||||
when {
|
|
||||||
dotTop < 0 && dotBot < 0 -> {
|
|
||||||
// Entire column is night — shade from top to bottom
|
|
||||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat())
|
|
||||||
canvas.drawRect(rect, nightPaint)
|
|
||||||
}
|
|
||||||
|
|
||||||
dotTop >= 0 && dotBot >= 0 -> {
|
|
||||||
// Entire column is day — nothing to draw
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
// Terminator crosses this column — find the crossing pixel by binary search
|
|
||||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
|
|
||||||
if (dotTop < 0) {
|
|
||||||
// Night at top, day at bottom
|
|
||||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
|
||||||
canvas.drawRect(rect, nightPaint)
|
|
||||||
} else {
|
|
||||||
// Day at top, night at bottom
|
|
||||||
rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat())
|
|
||||||
canvas.drawRect(rect, nightPaint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
x += stepPx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
|
||||||
* [yTop] is in day, [yBot] is in night (or vice versa).
|
|
||||||
*/
|
|
||||||
private fun findCrossingY(
|
|
||||||
proj: org.osmdroid.views.Projection,
|
|
||||||
x: Int,
|
|
||||||
yTop: Int,
|
|
||||||
yBot: Int,
|
|
||||||
sinSunLat: Double,
|
|
||||||
cosSunLat: Double,
|
|
||||||
cosLonDiff: Double
|
|
||||||
): Int {
|
|
||||||
var lo = yTop
|
|
||||||
var hi = yBot
|
|
||||||
while (hi - lo > 1) {
|
|
||||||
val mid = (lo + hi) / 2
|
|
||||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
|
||||||
val latRad = Math.toRadians(geo.latitude)
|
|
||||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
|
||||||
if (dot < 0) hi = mid else lo = mid
|
|
||||||
}
|
|
||||||
return (lo + hi) / 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,6 +60,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
@@ -71,37 +72,56 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
|||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import org.osmdroid.config.Configuration
|
||||||
|
import org.osmdroid.tileprovider.MapTileProviderBasic
|
||||||
|
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||||
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.util.MapTileIndex
|
||||||
import org.osmdroid.views.CustomZoomButtonsController
|
import org.osmdroid.views.CustomZoomButtonsController
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.FolderOverlay
|
import org.osmdroid.views.overlay.FolderOverlay
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
import org.osmdroid.views.overlay.Polyline
|
import org.osmdroid.views.overlay.Polyline
|
||||||
|
import org.osmdroid.views.overlay.TilesOverlay
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
// Overlay indices
|
// Overlay indices
|
||||||
private const val OVERLAY_STATION = 0
|
private const val OVERLAY_TDT_LABELS = 0
|
||||||
private const val OVERLAY_TRACK = 1
|
private const val OVERLAY_STATION = 1
|
||||||
private const val OVERLAY_FOOTPRINT = 2
|
private const val OVERLAY_TRACK = 2
|
||||||
private const val OVERLAY_POSITIONS = 3
|
private const val OVERLAY_FOOTPRINT = 3
|
||||||
private const val OVERLAY_TERMINATOR = 4
|
private const val OVERLAY_POSITIONS = 4
|
||||||
private const val OVERLAY_SUN = 5
|
private const val OVERLAY_SUN = 5
|
||||||
private const val OVERLAY_MOON = 6
|
private const val OVERLAY_MOON = 6
|
||||||
private const val OVERLAY_COUNT = 7
|
private const val OVERLAY_COUNT = 7
|
||||||
|
|
||||||
private val minLat = MapView.getTileSystem().minLatitude
|
private val minLat = MapView.getTileSystem().minLatitude
|
||||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||||
|
private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||||
|
private const val OFFLINE_MAX_ZOOM = 7.0
|
||||||
|
private const val OSM_MAX_ZOOM = 19.0
|
||||||
|
private const val TIANDITU_MAX_ZOOM = 18.0
|
||||||
|
private const val TILE_CACHE_MAX_BYTES = 1024L * 1024L * 1024L
|
||||||
|
private const val TILE_CACHE_TRIM_BYTES = 900L * 1024L * 1024L
|
||||||
|
private const val TILE_REFRESH_DELAY_MS = 250L
|
||||||
|
private const val TILE_SOURCE_OSM = "osm"
|
||||||
|
private const val TILE_USER_AGENT = "Look4Sat-CNMap"
|
||||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
strokeWidth = 3f
|
strokeWidth = 4f
|
||||||
style = Paint.Style.STROKE
|
style = Paint.Style.STROKE
|
||||||
color = Color.RED
|
color = "#1565C0".toColorInt()
|
||||||
strokeCap = Paint.Cap.ROUND
|
strokeCap = Paint.Cap.ROUND
|
||||||
strokeJoin = Paint.Join.ROUND
|
strokeJoin = Paint.Join.ROUND
|
||||||
}
|
}
|
||||||
private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val footprintOutlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
strokeWidth = 3f
|
strokeWidth = 4f
|
||||||
style = Paint.Style.FILL_AND_STROKE
|
style = Paint.Style.STROKE
|
||||||
color = "#FFE082".toColorInt()
|
color = "#00897B".toColorInt()
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
strokeJoin = Paint.Join.ROUND
|
||||||
}
|
}
|
||||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
textSize = 36f
|
textSize = 36f
|
||||||
@@ -134,11 +154,17 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
val rotateMod = Modifier.rotate(180f)
|
val rotateMod = Modifier.rotate(180f)
|
||||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||||
|
val usesTianditu = tiandituLayers(uiState.mapSource) != null && uiState.tiandituKey.isNotBlank()
|
||||||
|
val copyrightResId = if (usesTianditu) R.string.map_copyright_tianditu
|
||||||
|
else R.string.map_copyright_osm
|
||||||
|
|
||||||
LaunchedEffect(uiState.track) {
|
LaunchedEffect(uiState.track) {
|
||||||
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||||
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
|
||||||
|
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
|
||||||
|
}
|
||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
val isVertical = isVerticalLayout()
|
val isVertical = isVerticalLayout()
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
@@ -163,13 +189,12 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||||
uiState.footprint?.let { setFootprint(it, view) }
|
uiState.footprint?.let { setFootprint(it, view) }
|
||||||
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
||||||
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
|
||||||
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
||||||
view.invalidate()
|
view.invalidate()
|
||||||
}
|
}
|
||||||
uiState.mapData?.let { mapData ->
|
uiState.mapData?.let { mapData ->
|
||||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
if (isVertical) MapDataCard(mapData, copyrightResId) else MapDataCards(mapData, copyrightResId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,11 +203,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
|
|
||||||
// region Map data composables
|
// region Map data composables
|
||||||
@Composable
|
@Composable
|
||||||
private fun MapDataCard(data: MapData) {
|
private fun MapDataCard(data: MapData, copyrightResId: Int) {
|
||||||
val textColor = MaterialTheme.colorScheme.primary
|
val textColor = MaterialTheme.colorScheme.primary
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
Text(text = stringResource(copyrightResId), fontSize = 14.sp)
|
||||||
Card(colors = cardColors) {
|
Card(colors = cardColors) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
MapDataRow(
|
MapDataRow(
|
||||||
@@ -220,7 +245,7 @@ private fun MapDataRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MapDataCards(data: MapData) {
|
private fun MapDataCards(data: MapData, copyrightResId: Int) {
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
val paddingMod = Modifier
|
val paddingMod = Modifier
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
@@ -246,7 +271,7 @@ private fun MapDataCards(data: MapData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.map_copyright),
|
text = stringResource(copyrightResId),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
)
|
)
|
||||||
@@ -260,6 +285,114 @@ private fun MapDataCards(data: MapData) {
|
|||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
// region Tile sources
|
||||||
|
private var configuredTileMapView: MapView? = null
|
||||||
|
private var configuredTileSource: String? = null
|
||||||
|
|
||||||
|
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
|
||||||
|
val key = tiandituKey.trim()
|
||||||
|
val layers = tiandituLayers(mapSource)
|
||||||
|
val tileSourceKey = if (layers == null || key.isBlank()) {
|
||||||
|
TILE_SOURCE_OSM
|
||||||
|
} else {
|
||||||
|
"tianditu:${layers.base}:${layers.label}:$key"
|
||||||
|
}
|
||||||
|
if (configuredTileMapView === mapView && configuredTileSource == tileSourceKey) {
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configuredTileMapView = mapView
|
||||||
|
configuredTileSource = tileSourceKey
|
||||||
|
|
||||||
|
if (layers == null || key.isBlank()) {
|
||||||
|
mapView.setUseDataConnection(true)
|
||||||
|
mapView.setTileProvider(MapTileProviderBasic(mapView.context, TileSourceFactory.MAPNIK))
|
||||||
|
mapView.maxZoomLevel = OSM_MAX_ZOOM
|
||||||
|
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
|
||||||
|
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mapView.setUseDataConnection(true)
|
||||||
|
mapView.setTileProvider(MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.base, key = key)))
|
||||||
|
mapView.maxZoomLevel = TIANDITU_MAX_ZOOM
|
||||||
|
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
|
||||||
|
mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay(
|
||||||
|
MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)),
|
||||||
|
mapView.context
|
||||||
|
).apply {
|
||||||
|
applyTileOverlayDefaults()
|
||||||
|
setUseDataConnection(true)
|
||||||
|
}
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TiandituLayers(val base: String, val label: String)
|
||||||
|
|
||||||
|
private fun tiandituLayers(mapSource: String): TiandituLayers? = when (MapSource.normalize(mapSource)) {
|
||||||
|
MapSource.TIANDITU_VECTOR -> TiandituLayers(base = "vec", label = "cva")
|
||||||
|
MapSource.TIANDITU_IMAGE -> TiandituLayers(base = "img", label = "cia")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TilesOverlay.applyTileOverlayDefaults() {
|
||||||
|
setColorFilter(null)
|
||||||
|
setLoadingBackgroundColor(Color.TRANSPARENT)
|
||||||
|
setLoadingLineColor(Color.TRANSPARENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestTileRefresh(mapView: MapView) {
|
||||||
|
mapView.post {
|
||||||
|
mapView.requestLayout()
|
||||||
|
mapView.invalidate()
|
||||||
|
mapView.postInvalidate()
|
||||||
|
}
|
||||||
|
mapView.postDelayed({
|
||||||
|
mapView.invalidate()
|
||||||
|
mapView.postInvalidate()
|
||||||
|
}, TILE_REFRESH_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTiandituWmtsUrl(
|
||||||
|
baseUrl: String,
|
||||||
|
layer: String,
|
||||||
|
zoom: Int,
|
||||||
|
row: Int,
|
||||||
|
col: Int,
|
||||||
|
encodedKey: String
|
||||||
|
): String = "$baseUrl?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=$layer" +
|
||||||
|
"&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=$zoom&TILEROW=$row&TILECOL=$col&tk=$encodedKey"
|
||||||
|
|
||||||
|
private class TiandituTileSource(
|
||||||
|
private val layer: String,
|
||||||
|
private val key: String
|
||||||
|
) : OnlineTileSourceBase(
|
||||||
|
"tianditu-wmts-https-$layer",
|
||||||
|
1,
|
||||||
|
18,
|
||||||
|
256,
|
||||||
|
".png",
|
||||||
|
Array(8) { index -> "https://t$index.tianditu.gov.cn/${layer}_w/wmts" },
|
||||||
|
"Tianditu"
|
||||||
|
) {
|
||||||
|
private val encodedKey = URLEncoder.encode(key, Charsets.UTF_8.name())
|
||||||
|
|
||||||
|
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||||
|
val zoom = MapTileIndex.getZoom(pMapTileIndex)
|
||||||
|
val row = MapTileIndex.getY(pMapTileIndex)
|
||||||
|
val col = MapTileIndex.getX(pMapTileIndex)
|
||||||
|
return buildTiandituWmtsUrl(
|
||||||
|
baseUrl = getBaseUrl(),
|
||||||
|
layer = layer,
|
||||||
|
zoom = zoom,
|
||||||
|
row = row,
|
||||||
|
col = col,
|
||||||
|
encodedKey = encodedKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
// region Map overlay helpers
|
// region Map overlay helpers
|
||||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
@@ -282,6 +415,8 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
|||||||
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
||||||
private val markerPool = HashMap<String, Marker>()
|
private val markerPool = HashMap<String, Marker>()
|
||||||
private var lastMapView: MapView? = null
|
private var lastMapView: MapView? = null
|
||||||
|
private var lastSatelliteTrack: List<List<GeoPos>>? = null
|
||||||
|
private var satelliteTrackOverlay: FolderOverlay? = null
|
||||||
|
|
||||||
private fun setPositions(
|
private fun setPositions(
|
||||||
posMap: Map<OrbitalObject, GeoPos>,
|
posMap: Map<OrbitalObject, GeoPos>,
|
||||||
@@ -294,8 +429,9 @@ private fun setPositions(
|
|||||||
lastMapView = mapView
|
lastMapView = mapView
|
||||||
markerPool.clear()
|
markerPool.clear()
|
||||||
iconCache.evictAll()
|
iconCache.evictAll()
|
||||||
footprintPolyline = null
|
lastSatelliteTrack = null
|
||||||
footprintPoints = null
|
satelliteTrackOverlay = null
|
||||||
|
footprintOverlay = null
|
||||||
}
|
}
|
||||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||||
@@ -357,6 +493,10 @@ private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
||||||
|
val cachedOverlay = satelliteTrackOverlay
|
||||||
|
if (lastSatelliteTrack === satTrack && cachedOverlay != null && mapView.overlays[OVERLAY_TRACK] === cachedOverlay) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val trackOverlay = FolderOverlay()
|
val trackOverlay = FolderOverlay()
|
||||||
try {
|
try {
|
||||||
satTrack.forEach { track ->
|
satTrack.forEach { track ->
|
||||||
@@ -366,61 +506,76 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
|||||||
trackOverlay.add(this)
|
trackOverlay.add(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastSatelliteTrack = satTrack
|
||||||
|
satelliteTrackOverlay = trackOverlay
|
||||||
mapView.overlays[OVERLAY_TRACK] = trackOverlay
|
mapView.overlays[OVERLAY_TRACK] = trackOverlay
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println(e)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reusable footprint Polyline — created once, points updated in-place each frame */
|
/** Reusable footprint overlay - split at the date line so wrapped maps don't drop the circle. */
|
||||||
private var footprintPolyline: Polyline? = null
|
private var footprintOverlay: FolderOverlay? = null
|
||||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
|
||||||
|
|
||||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
val rangeCircle = orbitalPos.getRangeCircle()
|
val rangeCircle = orbitalPos.getRangeCircle()
|
||||||
var pts = footprintPoints
|
val overlay = footprintOverlay ?: FolderOverlay().also { footprintOverlay = it }
|
||||||
if (pts == null || pts.size != rangeCircle.size) {
|
overlay.items.clear()
|
||||||
pts = ArrayList(rangeCircle.size)
|
splitClosedPathOnDateLine(rangeCircle).forEach { segment ->
|
||||||
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
|
if (segment.size > 1) {
|
||||||
footprintPoints = pts
|
overlay.add(
|
||||||
} else {
|
Polyline().apply {
|
||||||
for (i in rangeCircle.indices) {
|
setPoints(segment.map { GeoPoint(it.latitude, it.longitude) })
|
||||||
pts[i].latitude = rangeCircle[i].latitude
|
outlinePaint.set(footprintOutlinePaint)
|
||||||
pts[i].longitude = rangeCircle[i].longitude
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val polyline = footprintPolyline ?: Polyline().apply {
|
mapView.overlays[OVERLAY_FOOTPRINT] = overlay
|
||||||
outlinePaint.set(footprintPaint)
|
|
||||||
footprintPolyline = this
|
|
||||||
}
|
|
||||||
polyline.setPoints(pts)
|
|
||||||
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println(e)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun splitClosedPathOnDateLine(points: List<GeoPos>): List<List<GeoPos>> {
|
||||||
* Update the NightOverlay with the current sub-solar position.
|
if (points.isEmpty()) return emptyList()
|
||||||
* The overlay is created once and kept in OVERLAY_TERMINATOR; only its
|
val segments = mutableListOf<List<GeoPos>>()
|
||||||
* sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it.
|
val segment = mutableListOf<GeoPos>()
|
||||||
*/
|
var previous = points.first()
|
||||||
private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
|
segment.add(previous)
|
||||||
try {
|
(points.drop(1) + points.first()).forEach { current ->
|
||||||
val overlay = mapView.overlays[OVERLAY_TERMINATOR]
|
when {
|
||||||
if (overlay is MapNightOverlay) {
|
previous.longitude < -170.0 && current.longitude > 170.0 -> {
|
||||||
overlay.sunLatDeg = sunLatDeg
|
val edgeLatitude = getDateLineLatitude(previous, current, -180.0)
|
||||||
overlay.sunLonDeg = sunLonDeg
|
segment.add(GeoPos(edgeLatitude, -180.0))
|
||||||
} else {
|
segments.add(segment.toList())
|
||||||
mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply {
|
segment.clear()
|
||||||
this.sunLatDeg = sunLatDeg
|
segment.add(GeoPos(edgeLatitude, 180.0))
|
||||||
this.sunLonDeg = sunLonDeg
|
}
|
||||||
|
previous.longitude > 170.0 && current.longitude < -170.0 -> {
|
||||||
|
val edgeLatitude = getDateLineLatitude(previous, current, 180.0)
|
||||||
|
segment.add(GeoPos(edgeLatitude, 180.0))
|
||||||
|
segments.add(segment.toList())
|
||||||
|
segment.clear()
|
||||||
|
segment.add(GeoPos(edgeLatitude, -180.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
segment.add(current)
|
||||||
println(e)
|
previous = current
|
||||||
}
|
}
|
||||||
|
if (segment.size > 1) segments.add(segment)
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDateLineLatitude(previous: GeoPos, current: GeoPos, edgeLongitude: Double): Double {
|
||||||
|
val currentLongitude = when {
|
||||||
|
previous.longitude < -170.0 && current.longitude > 170.0 -> current.longitude - 360.0
|
||||||
|
previous.longitude > 170.0 && current.longitude < -170.0 -> current.longitude + 360.0
|
||||||
|
else -> current.longitude
|
||||||
|
}
|
||||||
|
val fraction = (edgeLongitude - previous.longitude) / (currentLongitude - previous.longitude)
|
||||||
|
return previous.latitude + (current.latitude - previous.latitude) * fraction
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Place an ic_sun icon marker at the sub-solar point. */
|
/** Place an ic_sun icon marker at the sub-solar point. */
|
||||||
@@ -482,40 +637,62 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
|
|||||||
// region MapView lifecycle
|
// region MapView lifecycle
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewWithLifecycle(): MapView {
|
private fun rememberMapViewWithLifecycle(): MapView {
|
||||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isVertical = isVerticalLayout()
|
val isVertical = isVerticalLayout()
|
||||||
val mapView = remember {
|
val mapView = remember {
|
||||||
|
configureOsmdroidCache(context)
|
||||||
MapView(context).apply {
|
MapView(context).apply {
|
||||||
setMultiTouchControls(true)
|
setMultiTouchControls(true)
|
||||||
setUseDataConnection(false)
|
setUseDataConnection(false)
|
||||||
setTileSource(tileSource)
|
setTileSource(offlineTileSource)
|
||||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||||
maxZoomLevel = 7.0
|
maxZoomLevel = OFFLINE_MAX_ZOOM
|
||||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||||
controller.setZoom(minZoomLevel + 2)
|
controller.setZoom(minZoomLevel + 2)
|
||||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||||
|
setHorizontalMapRepetitionEnabled(true)
|
||||||
|
setVerticalMapRepetitionEnabled(false)
|
||||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||||
|
addOnFirstLayoutListener { _, _, _, _, _ -> requestTileRefresh(this) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
lifecycle.addObserver(lifecycleObserver)
|
lifecycle.addObserver(lifecycleObserver)
|
||||||
|
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||||
|
mapView.onResume()
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
}
|
||||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||||
}
|
}
|
||||||
return mapView
|
return mapView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun configureOsmdroidCache(context: android.content.Context) {
|
||||||
|
val cacheRoot = File(context.filesDir, "osmdroid")
|
||||||
|
val tileCache = File(cacheRoot, "tiles")
|
||||||
|
Configuration.getInstance().apply {
|
||||||
|
setUserAgentValue(TILE_USER_AGENT)
|
||||||
|
setOsmdroidBasePath(cacheRoot)
|
||||||
|
setOsmdroidTileCache(tileCache)
|
||||||
|
setTileFileSystemCacheMaxBytes(TILE_CACHE_MAX_BYTES)
|
||||||
|
setTileFileSystemCacheTrimBytes(TILE_CACHE_TRIM_BYTES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||||
LifecycleEventObserver { _, event ->
|
LifecycleEventObserver { _, event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
|
mapView.onResume()
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
}
|
||||||
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ data class MapState(
|
|||||||
val mapData: MapData? = null,
|
val mapData: MapData? = null,
|
||||||
val isLightUi: Boolean = false,
|
val isLightUi: Boolean = false,
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
|
val mapSource: String = "",
|
||||||
|
val tiandituKey: String = "",
|
||||||
val stationPosition: GeoPos? = null,
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>? = null,
|
val track: List<List<GeoPos>>? = null,
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ class MapViewModel(
|
|||||||
MapState(
|
MapState(
|
||||||
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
|
mapSource = settingsRepo.otherSettings.value.mapSource,
|
||||||
|
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
|
||||||
orbitalPass = defaultPass
|
orbitalPass = defaultPass
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -74,7 +76,13 @@ class MapViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUtc = settings.stateOfUtc,
|
||||||
|
mapSource = settings.mapSource,
|
||||||
|
tiandituKey = settings.tiandituKey
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
@@ -269,29 +277,45 @@ class MapViewModel(
|
|||||||
val satTracks = mutableListOf<List<GeoPos>>()
|
val satTracks = mutableListOf<List<GeoPos>>()
|
||||||
val currentTrack = mutableListOf<GeoPos>()
|
val currentTrack = mutableListOf<GeoPos>()
|
||||||
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
||||||
var oldLongitude = 0.0
|
var previousPosition: GeoPos? = null
|
||||||
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
||||||
val currentPosition = satPos.toMapGeoPos()
|
val currentPosition = satPos.toMapGeoPos()
|
||||||
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
val previous = previousPosition
|
||||||
|
if (previous != null && previous.longitude < -170.0 && currentPosition.longitude > 170.0) {
|
||||||
// adding left terminal position
|
// adding left terminal position
|
||||||
currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
|
val edgeLatitude = getDateLineLatitude(previous, currentPosition)
|
||||||
|
currentTrack.add(GeoPos(edgeLatitude, -180.0))
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||||
satTracks.add(finishedTrack)
|
satTracks.add(finishedTrack)
|
||||||
currentTrack.clear()
|
currentTrack.clear()
|
||||||
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
currentTrack.add(GeoPos(edgeLatitude, 180.0))
|
||||||
|
} else if (previous != null && previous.longitude > 170.0 && currentPosition.longitude < -170.0) {
|
||||||
// adding right terminal position
|
// adding right terminal position
|
||||||
currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
|
val edgeLatitude = getDateLineLatitude(previous, currentPosition)
|
||||||
|
currentTrack.add(GeoPos(edgeLatitude, 180.0))
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||||
satTracks.add(finishedTrack)
|
satTracks.add(finishedTrack)
|
||||||
currentTrack.clear()
|
currentTrack.clear()
|
||||||
|
currentTrack.add(GeoPos(edgeLatitude, -180.0))
|
||||||
}
|
}
|
||||||
oldLongitude = currentPosition.longitude
|
previousPosition = currentPosition
|
||||||
currentTrack.add(currentPosition)
|
currentTrack.add(currentPosition)
|
||||||
}
|
}
|
||||||
satTracks.add(currentTrack)
|
satTracks.add(currentTrack)
|
||||||
_uiState.update { it.copy(track = satTracks) }
|
_uiState.update { it.copy(track = satTracks) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getDateLineLatitude(previous: GeoPos, current: GeoPos): Double {
|
||||||
|
val currentLon = if (previous.longitude < 0.0 && current.longitude > 0.0) {
|
||||||
|
current.longitude - 360.0
|
||||||
|
} else {
|
||||||
|
current.longitude + 360.0
|
||||||
|
}
|
||||||
|
val edgeLon = if (previous.longitude < 0.0) -180.0 else 180.0
|
||||||
|
val fraction = (edgeLon - previous.longitude) / (currentLon - previous.longitude)
|
||||||
|
return previous.latitude + (current.latitude - previous.latitude) * fraction
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Number of parallel chunks for satellite position computation */
|
/** Number of parallel chunks for satellite position computation */
|
||||||
private const val PARALLEL_CHUNKS = 4
|
private const val PARALLEL_CHUNKS = 4
|
||||||
|
|||||||
+30
-227
@@ -21,11 +21,8 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
@@ -34,7 +31,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RangeSlider
|
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -58,89 +54,45 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
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.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 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
|
|
||||||
private const val endOfDayMinute = dayMinutes - 1
|
|
||||||
private const val quarterHourSlots = dayMinutes / minuteStep
|
|
||||||
|
|
||||||
data class PassFilterParams(
|
|
||||||
val hours: Int,
|
|
||||||
val elevation: Double,
|
|
||||||
val lowElevation: Double,
|
|
||||||
val highElevation: Double,
|
|
||||||
val aosStartMinute: Int,
|
|
||||||
val aosEndMinute: Int,
|
|
||||||
val invertAosTimeWindow: Boolean,
|
|
||||||
val showDeepSpace: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesDialogPreview() {
|
private fun PassesDialogPreview() {
|
||||||
MainTheme {
|
MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } }
|
||||||
PassesFilterDialog(
|
|
||||||
hours = 24,
|
|
||||||
elevation = 16.0,
|
|
||||||
lowElevation = 16.0,
|
|
||||||
highElevation = 65.0,
|
|
||||||
aosStartMinute = 0,
|
|
||||||
aosEndMinute = 23 * 60 + 59,
|
|
||||||
invertAosTimeWindow = false,
|
|
||||||
showDeepSpace = true,
|
|
||||||
cancel = {},
|
|
||||||
accept = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PassesFilterDialog(
|
internal fun PassesDialog(
|
||||||
hours: Int,
|
hours: Int,
|
||||||
elevation: Double,
|
elevation: Double,
|
||||||
lowElevation: Double,
|
|
||||||
highElevation: Double,
|
|
||||||
aosStartMinute: Int,
|
|
||||||
aosEndMinute: Int,
|
|
||||||
invertAosTimeWindow: Boolean,
|
|
||||||
showDeepSpace: Boolean,
|
showDeepSpace: Boolean,
|
||||||
cancel: () -> Unit,
|
cancel: () -> Unit,
|
||||||
accept: (PassFilterParams) -> Unit
|
accept: (Int, Double, Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
||||||
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
||||||
val highlightBounds = 0f..90f
|
|
||||||
var highlightRange by remember(lowElevation, highElevation) {
|
|
||||||
mutableStateOf(lowElevation.toFloat()..highElevation.toFloat())
|
|
||||||
}
|
|
||||||
var aosRangeValue by remember {
|
|
||||||
mutableStateOf(minuteToSlot(aosStartMinute).toFloat()..minuteToSlot(aosEndMinute).toFloat())
|
|
||||||
}
|
|
||||||
var invertedAosRange by remember { mutableStateOf(invertAosTimeWindow) }
|
|
||||||
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
|
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
accept(
|
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() }
|
||||||
PassFilterParams(
|
|
||||||
hours = hourSteps[hoursIndex.intValue],
|
|
||||||
elevation = elevationValueNew.doubleValue,
|
|
||||||
lowElevation = highlightRange.start.roundToInt().toDouble(),
|
|
||||||
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
|
|
||||||
aosStartMinute = slotToMinute(aosRangeValue.start),
|
|
||||||
aosEndMinute = slotToMinute(aosRangeValue.endInclusive),
|
|
||||||
invertAosTimeWindow = invertedAosRange,
|
|
||||||
showDeepSpace = deepSpaceEnabled
|
|
||||||
)
|
|
||||||
)
|
|
||||||
cancel()
|
|
||||||
}
|
}
|
||||||
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.pass_filter_deep_space),
|
||||||
|
checked = deepSpaceEnabled,
|
||||||
|
onCheckedChange = { deepSpaceEnabled = it }
|
||||||
|
)
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_elev),
|
title = stringResource(R.string.pass_filter_elev),
|
||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
@@ -152,74 +104,24 @@ internal fun PassesFilterDialog(
|
|||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_hours),
|
title = stringResource(R.string.pass_filter_hours),
|
||||||
value = hoursIndex.intValue.toDouble(),
|
value = hoursIndex.intValue.toDouble(),
|
||||||
displayValue = formatHoursLabel(hourSteps[hoursIndex.intValue]),
|
displayValue = "${hourSteps[hoursIndex.intValue]}h",
|
||||||
valueResId = R.drawable.ic_clock,
|
valueResId = R.drawable.ic_clock,
|
||||||
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
||||||
steps = hourSteps.size - 2
|
steps = hourSteps.size - 2
|
||||||
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
||||||
ToggleRow(
|
|
||||||
title = stringResource(R.string.pass_filter_deep_space),
|
|
||||||
checked = deepSpaceEnabled,
|
|
||||||
onCheckedChange = { deepSpaceEnabled = it }
|
|
||||||
)
|
|
||||||
ElevationColorsRangeSliderRow(
|
|
||||||
title = stringResource(R.string.prefs_highlight_title),
|
|
||||||
range = highlightRange,
|
|
||||||
valueRange = highlightBounds
|
|
||||||
) { highlightRange = it }
|
|
||||||
TimeRangeSliderRow(
|
|
||||||
title = stringResource(R.string.pass_filter_aos_time),
|
|
||||||
range = aosRangeValue,
|
|
||||||
displayValue = formatTimeRange(aosRangeValue, invertedAosRange),
|
|
||||||
valueResId = R.drawable.ic_clock,
|
|
||||||
valueRange = 0f..quarterHourSlots.toFloat(),
|
|
||||||
) { aosRangeValue = it }
|
|
||||||
ToggleRow(
|
|
||||||
title = stringResource(R.string.pass_filter_invert_time),
|
|
||||||
checked = invertedAosRange,
|
|
||||||
onCheckedChange = { invertedAosRange = it }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun slotToMinute(value: Float): Int {
|
|
||||||
val slot = value.roundToInt().coerceIn(0, quarterHourSlots)
|
|
||||||
return if (slot == quarterHourSlots) endOfDayMinute else slot * minuteStep
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun minuteToSlot(minute: Int): Int {
|
|
||||||
if (minute >= endOfDayMinute) return quarterHourSlots
|
|
||||||
return ((minute + minuteStep / 2) / minuteStep).coerceIn(0, quarterHourSlots)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatMinuteOfDay(minute: Int): String {
|
|
||||||
val hourPart = minute / 60
|
|
||||||
val minutePart = minute % 60
|
|
||||||
return "%02d:%02d".format(hourPart, minutePart)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatHoursLabel(hours: Int): String {
|
|
||||||
if (hours < 24) return "${hours}h"
|
|
||||||
val days = hours / 24
|
|
||||||
val remainder = hours % 24
|
|
||||||
return if (remainder == 0) "${days}d" else "${days}d ${remainder}h"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatTimeRange(
|
|
||||||
range: ClosedFloatingPointRange<Float>,
|
|
||||||
inverted: Boolean
|
|
||||||
): String {
|
|
||||||
val start = formatMinuteOfDay(slotToMinute(range.start))
|
|
||||||
val end = formatMinuteOfDay(slotToMinute(range.endInclusive))
|
|
||||||
return if (inverted) "$end - $start" else "$start - $end"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SliderSection(
|
private fun SliderRow(
|
||||||
title: String,
|
title: String,
|
||||||
trailingContent: @Composable () -> Unit,
|
value: Double,
|
||||||
sliderContent: @Composable () -> Unit
|
displayValue: String,
|
||||||
|
valueResId: Int,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
steps: Int = 0,
|
||||||
|
accentColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
onChange: (Float) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -236,26 +138,6 @@ private fun SliderSection(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
trailingContent()
|
|
||||||
}
|
|
||||||
sliderContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SliderRow(
|
|
||||||
title: String,
|
|
||||||
value: Double,
|
|
||||||
displayValue: String,
|
|
||||||
valueResId: Int,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
|
||||||
steps: Int = 0,
|
|
||||||
accentColor: Color = MaterialTheme.colorScheme.primary,
|
|
||||||
onChange: (Float) -> Unit
|
|
||||||
) {
|
|
||||||
SliderSection(
|
|
||||||
title = title,
|
|
||||||
trailingContent = {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = valueResId),
|
painter = painterResource(id = valueResId),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -269,89 +151,10 @@ private fun SliderRow(
|
|||||||
color = accentColor
|
color = accentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TimeRangeSliderRow(
|
|
||||||
title: String,
|
|
||||||
range: ClosedFloatingPointRange<Float>,
|
|
||||||
displayValue: String,
|
|
||||||
valueResId: Int,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
|
||||||
onChange: (ClosedFloatingPointRange<Float>) -> Unit
|
|
||||||
) {
|
|
||||||
SliderSection(
|
|
||||||
title = title,
|
|
||||||
trailingContent = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = valueResId),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = displayValue,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
RangeSlider(
|
|
||||||
value = range,
|
|
||||||
onValueChange = { onChange(it.start..it.endInclusive) },
|
|
||||||
valueRange = valueRange,
|
|
||||||
steps = 0,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ElevationColorsRangeSliderRow(
|
|
||||||
title: String,
|
|
||||||
range: ClosedFloatingPointRange<Float>,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
|
||||||
onChange: (ClosedFloatingPointRange<Float>) -> Unit
|
|
||||||
) {
|
|
||||||
val low = range.start.roundToInt()
|
|
||||||
val high = range.endInclusive.roundToInt()
|
|
||||||
SliderSection(
|
|
||||||
title = title,
|
|
||||||
trailingContent = {
|
|
||||||
Text(
|
|
||||||
text = "$low°",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = ElevationLowColor
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "..",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "$high°",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = ElevationHighColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
RangeSlider(
|
|
||||||
value = range,
|
|
||||||
onValueChange = { onChange(it.start..it.endInclusive) },
|
|
||||||
valueRange = valueRange,
|
|
||||||
steps = 0,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadiosDialogPreview() {
|
private fun RadiosDialogPreview() {
|
||||||
@@ -365,7 +168,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
|||||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
||||||
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(240.dp),
|
columns = GridCells.Adaptive(240.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -374,7 +177,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(Sources.satelliteModes) { index, item ->
|
itemsIndexed(allModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
|
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
@@ -74,7 +75,6 @@ import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
|||||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
@@ -99,31 +99,14 @@ private fun PassesScreen(
|
|||||||
navigateToRadar: (Int, Long) -> Unit
|
navigateToRadar: (Int, Long) -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isPassesDialogShown) {
|
if (uiState.isPassesDialogShown) {
|
||||||
PassesFilterDialog(
|
PassesDialog(
|
||||||
hours = uiState.hours,
|
hours = uiState.hours,
|
||||||
elevation = uiState.elevation,
|
elevation = uiState.elevation,
|
||||||
lowElevation = uiState.lowElevation,
|
|
||||||
highElevation = uiState.highElevation,
|
|
||||||
aosStartMinute = uiState.aosStartMinute,
|
|
||||||
aosEndMinute = uiState.aosEndMinute,
|
|
||||||
invertAosTimeWindow = uiState.invertAosTimeWindow,
|
|
||||||
showDeepSpace = uiState.showDeepSpace,
|
showDeepSpace = uiState.showDeepSpace,
|
||||||
cancel = { onAction(PassesAction.TogglePassesDialog) },
|
cancel = { onAction(PassesAction.TogglePassesDialog) }
|
||||||
accept = { params ->
|
) { hours, elevation, showDeepSpace ->
|
||||||
onAction(
|
onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
|
||||||
PassesAction.FilterPasses(
|
}
|
||||||
hoursAhead = params.hours,
|
|
||||||
minElevation = params.elevation,
|
|
||||||
lowElevation = params.lowElevation,
|
|
||||||
highElevation = params.highElevation,
|
|
||||||
aosStartMinute = params.aosStartMinute,
|
|
||||||
aosEndMinute = params.aosEndMinute,
|
|
||||||
invertAosTimeWindow = params.invertAosTimeWindow,
|
|
||||||
showDeepSpace = params.showDeepSpace
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (uiState.isRadiosDialogShown) {
|
if (uiState.isRadiosDialogShown) {
|
||||||
RadiosDialog(
|
RadiosDialog(
|
||||||
@@ -134,15 +117,19 @@ private fun PassesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWhatsNew) {
|
if (uiState.shouldSeeWhatsNew) {
|
||||||
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
InfoDialog(
|
||||||
WhatsNewDialog(onDismiss = dismiss)
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
|
text = stringResource(R.string.pass_whatsnew_message)
|
||||||
|
) {
|
||||||
|
onAction(PassesAction.DismissWhatsNew)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVerticalLayout,
|
isVerticalLayout = isVerticalLayout,
|
||||||
startAction = {
|
startAction = {
|
||||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||||
@@ -151,7 +138,7 @@ private fun PassesScreen(
|
|||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -278,11 +265,6 @@ 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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeepSpacePassPreview() {
|
private fun DeepSpacePassPreview() {
|
||||||
@@ -315,7 +297,7 @@ private fun PassItem(
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ data class PassesState(
|
|||||||
val isNextTimeAos: Boolean = true,
|
val isNextTimeAos: Boolean = true,
|
||||||
val hours: Int = 24,
|
val hours: Int = 24,
|
||||||
val elevation: Double = 16.0,
|
val elevation: Double = 16.0,
|
||||||
val lowElevation: Double = 16.0,
|
|
||||||
val highElevation: Double = 65.0,
|
|
||||||
val aosStartMinute: Int = 0,
|
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
|
||||||
val invertAosTimeWindow: Boolean = false,
|
|
||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val modes: List<String> = emptyList(),
|
val modes: List<String> = emptyList(),
|
||||||
val itemsList: List<OrbitalPass> = emptyList(),
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
@@ -45,16 +40,7 @@ data class PassesState(
|
|||||||
|
|
||||||
sealed interface PassesAction {
|
sealed interface PassesAction {
|
||||||
data object DismissWhatsNew : PassesAction
|
data object DismissWhatsNew : PassesAction
|
||||||
data class FilterPasses(
|
data class FilterPasses(val hoursAhead: Int, val minElevation: Double, val showDeepSpace: Boolean) : PassesAction
|
||||||
val hoursAhead: Int,
|
|
||||||
val minElevation: Double,
|
|
||||||
val lowElevation: Double,
|
|
||||||
val highElevation: Double,
|
|
||||||
val aosStartMinute: Int,
|
|
||||||
val aosEndMinute: Int,
|
|
||||||
val invertAosTimeWindow: Boolean,
|
|
||||||
val showDeepSpace: Boolean
|
|
||||||
) : PassesAction
|
|
||||||
data class FilterRadios(val modes: List<String>) : PassesAction
|
data class FilterRadios(val modes: List<String>) : PassesAction
|
||||||
data object RefreshPasses : PassesAction
|
data object RefreshPasses : PassesAction
|
||||||
data object TogglePassesDialog : PassesAction
|
data object TogglePassesDialog : PassesAction
|
||||||
|
|||||||
+38
-142
@@ -34,9 +34,6 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
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.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -44,7 +41,6 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class PassesViewModel(
|
class PassesViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -58,13 +54,8 @@ class PassesViewModel(
|
|||||||
nextPass = defaultPass,
|
nextPass = defaultPass,
|
||||||
hours = settingsRepo.passesSettings.value.hoursAhead,
|
hours = settingsRepo.passesSettings.value.hoursAhead,
|
||||||
elevation = settingsRepo.passesSettings.value.minElevation,
|
elevation = settingsRepo.passesSettings.value.minElevation,
|
||||||
lowElevation = settingsRepo.otherSettings.value.lowElevation,
|
|
||||||
highElevation = settingsRepo.otherSettings.value.highElevation,
|
|
||||||
aosStartMinute = settingsRepo.passesSettings.value.aosStartMinute,
|
|
||||||
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
|
||||||
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
|
||||||
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
||||||
modes = settingsRepo.selectedSatModes.value,
|
modes = settingsRepo.passesSettings.value.selectedModes,
|
||||||
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -80,52 +71,35 @@ class PassesViewModel(
|
|||||||
// React to settings changes: update UTC flag and whatsNew
|
// React to settings changes: update UTC flag and whatsNew
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update {
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
||||||
it.copy(
|
|
||||||
isUtc = settings.stateOfUtc,
|
|
||||||
shouldSeeWhatsNew = settings.shouldSeeWhatsNew,
|
|
||||||
lowElevation = settings.lowElevation,
|
|
||||||
highElevation = settings.highElevation
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Main tick loop: reacts to new passes, then ticks every second
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.selectedSatModes.collectLatest { modes ->
|
satelliteRepo.passes.collectLatest { allPasses ->
|
||||||
_uiState.update { it.copy(modes = modes) }
|
while (isActive) {
|
||||||
}
|
val timeNow = System.currentTimeMillis()
|
||||||
}
|
val isUtc = _uiState.value.isUtc
|
||||||
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
val showDeepSpace = _uiState.value.showDeepSpace
|
||||||
// computations run once per restart, progress/countdown are calculated every second.
|
val filtered = allPasses
|
||||||
viewModelScope.launch {
|
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||||
combine(
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
satelliteRepo.passes,
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
val sunTimes = computeSunTimes(processed, isUtc)
|
||||||
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
val grouped = groupPasses(processed, isUtc)
|
||||||
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
_uiState.update {
|
||||||
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
it.copy(
|
||||||
val filtered = if (showDeepSpace) allPasses
|
itemsList = processed,
|
||||||
else allPasses.filter { !it.isDeepSpace }
|
groupedPasses = grouped,
|
||||||
// Expensive: recompute sun times once per items/UTC change, not every second
|
sunTimes = sunTimes,
|
||||||
val sunTimes = computeSunTimes(filtered, isUtc)
|
nextPass = nextPass,
|
||||||
_uiState.update { it.copy(sunTimes = sunTimes) }
|
nextTime = nextTime,
|
||||||
while (isActive) {
|
isNextTimeAos = isAos
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
delay(1000)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,17 +107,9 @@ class PassesViewModel(
|
|||||||
when (action) {
|
when (action) {
|
||||||
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
||||||
is PassesAction.FilterPasses ->
|
is PassesAction.FilterPasses ->
|
||||||
applyFilter(
|
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
|
||||||
hoursAhead = action.hoursAhead,
|
is PassesAction.FilterRadios ->
|
||||||
minElevation = action.minElevation,
|
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
|
||||||
lowElevation = action.lowElevation,
|
|
||||||
highElevation = action.highElevation,
|
|
||||||
aosStartMinute = action.aosStartMinute,
|
|
||||||
aosEndMinute = action.aosEndMinute,
|
|
||||||
invertAosTimeWindow = action.invertAosTimeWindow,
|
|
||||||
showDeepSpace = action.showDeepSpace
|
|
||||||
)
|
|
||||||
is PassesAction.FilterRadios -> setModesFilter(action.modes)
|
|
||||||
PassesAction.RefreshPasses -> refreshPasses()
|
PassesAction.RefreshPasses -> refreshPasses()
|
||||||
PassesAction.TogglePassesDialog ->
|
PassesAction.TogglePassesDialog ->
|
||||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
@@ -154,28 +120,12 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 locale = displayLocale()
|
|
||||||
val pattern = if (locale.language == Locale.CHINESE.language) {
|
|
||||||
"yyyy'年'M'月'd'日' EEEE"
|
|
||||||
} else {
|
|
||||||
"EEE, dd MMM yyyy"
|
|
||||||
}
|
|
||||||
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
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = dateFormat(tz)
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
@@ -198,7 +148,7 @@ class PassesViewModel(
|
|||||||
|
|
||||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = dateFormat(tz)
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
val deepSpace = passes.filter { it.isDeepSpace }
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
@@ -248,73 +198,19 @@ class PassesViewModel(
|
|||||||
private fun applyFilter(
|
private fun applyFilter(
|
||||||
hoursAhead: Int,
|
hoursAhead: Int,
|
||||||
minElevation: Double,
|
minElevation: Double,
|
||||||
lowElevation: Double,
|
showDeepSpace: Boolean,
|
||||||
highElevation: Double,
|
modes: List<String>
|
||||||
aosStartMinute: Int,
|
|
||||||
aosEndMinute: Int,
|
|
||||||
invertAosTimeWindow: Boolean,
|
|
||||||
showDeepSpace: Boolean
|
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
settingsRepo.setPassesSettings(
|
settingsRepo.setPassesSettings(PassesSettings(showDeepSpace, hoursAhead, minElevation, modes))
|
||||||
PassesSettings(
|
|
||||||
showDeepSpace,
|
|
||||||
hoursAhead,
|
|
||||||
minElevation,
|
|
||||||
aosStartMinute,
|
|
||||||
aosEndMinute,
|
|
||||||
invertAosTimeWindow
|
|
||||||
)
|
|
||||||
)
|
|
||||||
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(hours = hoursAhead, elevation = minElevation, showDeepSpace = showDeepSpace, modes = modes)
|
||||||
hours = hoursAhead,
|
|
||||||
elevation = minElevation,
|
|
||||||
lowElevation = lowElevation,
|
|
||||||
highElevation = highElevation,
|
|
||||||
aosStartMinute = aosStartMinute,
|
|
||||||
aosEndMinute = aosEndMinute,
|
|
||||||
invertAosTimeWindow = invertAosTimeWindow,
|
|
||||||
showDeepSpace = showDeepSpace
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val modes = settingsRepo.selectedSatModes.value
|
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
||||||
satelliteRepo.calculatePasses(
|
|
||||||
time = System.currentTimeMillis(),
|
|
||||||
hoursAhead = hoursAhead,
|
|
||||||
minElevation = minElevation,
|
|
||||||
aosStartMinute = aosStartMinute,
|
|
||||||
aosEndMinute = aosEndMinute,
|
|
||||||
invertAosTimeWindow = invertAosTimeWindow,
|
|
||||||
modes = modes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private fun refreshPasses() = viewModelScope.launch {
|
||||||
val settings = settingsRepo.passesSettings.value
|
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
||||||
satelliteRepo.calculatePasses(
|
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
||||||
time = System.currentTimeMillis(),
|
|
||||||
hoursAhead = settings.hoursAhead,
|
|
||||||
minElevation = settings.minElevation,
|
|
||||||
aosStartMinute = settings.aosStartMinute,
|
|
||||||
aosEndMinute = settings.aosEndMinute,
|
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
|
||||||
modes = settingsRepo.selectedSatModes.value
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -60,7 +59,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
@@ -76,7 +74,6 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
Calculator("Calculator"),
|
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,31 +150,16 @@ private fun PagerCard(
|
|||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
val pages = RadarPage.entries
|
||||||
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 pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(pages.size) {
|
|
||||||
val lastPage = pages.lastIndex
|
|
||||||
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
||||||
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTabIndex == index,
|
selected = pagerState.currentPage == index,
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
)
|
)
|
||||||
@@ -194,13 +176,6 @@ private fun PagerCard(
|
|||||||
radioControl = uiState.radioControl,
|
radioControl = uiState.radioControl,
|
||||||
onAction = onAction
|
onAction = onAction
|
||||||
)
|
)
|
||||||
RadarPage.Calculator -> CalculatorPage(
|
|
||||||
transceivers = uiState.transceivers.transmitters,
|
|
||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
|
||||||
orbitalPos = uiState.orbitalPos,
|
|
||||||
onAction = onAction,
|
|
||||||
calculatorOffsetKHz = uiState.calculatorOffsetKHz
|
|
||||||
)
|
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
@@ -250,7 +225,6 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
|||||||
azimElev = uiState.orientationValues,
|
azimElev = uiState.orientationValues,
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
shouldShowSweep = uiState.shouldShowSweep,
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
shouldUseCompass = uiState.shouldUseCompass,
|
||||||
shouldFlipRadar = uiState.shouldFlipRadar,
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
sunPosition = uiState.sunPosition,
|
sunPosition = uiState.sunPosition,
|
||||||
moonPosition = uiState.moonPosition,
|
moonPosition = uiState.moonPosition,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
||||||
import com.rtbishop.look4sat.core.domain.sstv.SstvQualityMetrics
|
|
||||||
|
|
||||||
data class RadioPanelState(
|
data class RadioPanelState(
|
||||||
val label: String = "",
|
val label: String = "",
|
||||||
@@ -58,13 +57,11 @@ data class RadarState(
|
|||||||
val satTrack: List<OrbitalPos> = emptyList(),
|
val satTrack: List<OrbitalPos> = emptyList(),
|
||||||
val shouldShowSweep: Boolean = false,
|
val shouldShowSweep: Boolean = false,
|
||||||
val shouldUseCompass: Boolean = false,
|
val shouldUseCompass: Boolean = false,
|
||||||
val shouldFlipRadar: Boolean = false,
|
|
||||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
val transceivers: TransceiverSubState = TransceiverSubState(),
|
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||||
val sstv: SstvSubState = SstvSubState(),
|
val sstv: SstvSubState = SstvSubState()
|
||||||
val calculatorOffsetKHz: String = ""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class SstvStatus { Idle, Recording }
|
enum class SstvStatus { Idle, Recording }
|
||||||
@@ -75,8 +72,7 @@ data class SstvSubState(
|
|||||||
val hasPermission: Boolean = false,
|
val hasPermission: Boolean = false,
|
||||||
val selectedMode: String = "Auto",
|
val selectedMode: String = "Auto",
|
||||||
val supportedModes: List<String> = emptyList(),
|
val supportedModes: List<String> = emptyList(),
|
||||||
val currentFrame: SstvFrame? = null,
|
val currentFrame: SstvFrame? = null
|
||||||
val diagnosticsMetrics: SstvQualityMetrics? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface RadarAction {
|
sealed interface RadarAction {
|
||||||
@@ -98,7 +94,4 @@ sealed interface RadarAction {
|
|||||||
data object SstvReset : RadarAction
|
data object SstvReset : RadarAction
|
||||||
data class SstvSelectMode(val modeName: String) : RadarAction
|
data class SstvSelectMode(val modeName: String) : RadarAction
|
||||||
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
||||||
|
|
||||||
// Calculator actions
|
|
||||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,6 @@ fun RadarViewCompose(
|
|||||||
azimElev: Pair<Float, Float>,
|
azimElev: Pair<Float, Float>,
|
||||||
shouldShowSweep: Boolean,
|
shouldShowSweep: Boolean,
|
||||||
shouldUseCompass: Boolean,
|
shouldUseCompass: Boolean,
|
||||||
shouldFlipRadar: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sunPosition: CelestialComputer.SunPosition? = null,
|
sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
moonPosition: CelestialComputer.MoonPosition? = null,
|
moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
@@ -126,9 +125,7 @@ fun RadarViewCompose(
|
|||||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedSweepColor = primaryColor
|
cachedSweepColor = primaryColor
|
||||||
}
|
}
|
||||||
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
|
rotate(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) }
|
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||||
drawRadar(radius, radarColor)
|
drawRadar(radius, radarColor)
|
||||||
drawElevationLabels(radius, primaryColor, measurer)
|
drawElevationLabels(radius, primaryColor, measurer)
|
||||||
|
|||||||
+16
-123
@@ -32,7 +32,6 @@ import com.rtbishop.look4sat.core.domain.repository.IReporter
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
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.sstv.SstvDecoder
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
@@ -65,14 +64,11 @@ class RadarViewModel(
|
|||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private var stationPos = settingsRepo.stationPosition.value
|
private val stationPos = settingsRepo.stationPosition.value
|
||||||
private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
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 transponders: List<SatRadio> = emptyList()
|
||||||
private var sstvDecoder: SstvDecoder? = null
|
private var sstvDecoder: SstvDecoder? = null
|
||||||
private var sstvRecordingJob: Job? = null
|
private var sstvRecordingJob: Job? = null
|
||||||
private var sensorCollectionJob: Job? = null
|
|
||||||
|
|
||||||
// Celestial positions change slowly, recompute at most once per minute
|
// Celestial positions change slowly, recompute at most once per minute
|
||||||
private var lastCelestialUpdateMs = 0L
|
private var lastCelestialUpdateMs = 0L
|
||||||
@@ -91,67 +87,33 @@ class RadarViewModel(
|
|||||||
val uiState: StateFlow<RadarState> = _uiState
|
val uiState: StateFlow<RadarState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectSettingsChanges() // also handles initial sensor subscription
|
collectCompassSensor()
|
||||||
collectStationPositionChanges()
|
collectSettingsChanges()
|
||||||
collectPassAndStartTickLoop()
|
collectPassAndStartTickLoop()
|
||||||
collectRadioTrackingState()
|
collectRadioTrackingState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts sensor collection if not already running.
|
private fun collectCompassSensor() {
|
||||||
private fun startSensorCollection() {
|
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||||
if (sensorCollectionJob?.isActive == true) return
|
viewModelScope.launch {
|
||||||
sensorCollectionJob = viewModelScope.launch {
|
|
||||||
sensorsRepo.enableSensor()
|
sensorsRepo.enableSensor()
|
||||||
sensorsRepo.sensorData.collect { data ->
|
sensorsRepo.sensorData.collect { data ->
|
||||||
val orientationValues =
|
val orientationValues = (data.first + magDeclination) to data.second
|
||||||
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
|
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
_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() {
|
private fun collectSettingsChanges() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collect { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
compassOffset = settings.radarCompassOffset
|
|
||||||
compassOffsetElev = settings.radarCompassOffsetElev
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isUtc = settings.stateOfUtc,
|
isUtc = settings.stateOfUtc,
|
||||||
shouldShowSweep = settings.stateOfSweep,
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collectStationPositionChanges() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.stationPosition.collect { newPos ->
|
|
||||||
if (stationPos != newPos) {
|
|
||||||
stationPos = newPos
|
|
||||||
magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
|
||||||
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
|
|
||||||
val pass = _uiState.value.currentPass ?: return@collect
|
|
||||||
if (!pass.isDeepSpace) {
|
|
||||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
|
||||||
_uiState.update { it.copy(satTrack = track) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +212,8 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
stopSensorCollection()
|
sensorsRepo.disableSensor()
|
||||||
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
@@ -260,17 +223,7 @@ class RadarViewModel(
|
|||||||
// Compute toggle state before the update so we don't read post-update value
|
// Compute toggle state before the update so we don't read post-update value
|
||||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||||
val newUuid = if (isTogglingOff) null else action.uuid
|
val newUuid = if (isTogglingOff) null else action.uuid
|
||||||
// Load the saved offset for the newly selected satellite
|
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||||
val offsetKHz = if (!isTogglingOff) {
|
|
||||||
transponders.find { it.uuid == action.uuid }
|
|
||||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
|
||||||
} else ""
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
|
||||||
calculatorOffsetKHz = offsetKHz
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Only update the tracking service when selecting a different transponder to
|
// Only update the tracking service when selecting a different transponder to
|
||||||
// avoid resetting a user-adjusted TX base on re-expand
|
// avoid resetting a user-adjusted TX base on re-expand
|
||||||
if (!isTogglingOff) {
|
if (!isTogglingOff) {
|
||||||
@@ -319,15 +272,6 @@ class RadarViewModel(
|
|||||||
sstvDecoder?.clearPixels()
|
sstvDecoder?.clearPixels()
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||||
}
|
}
|
||||||
is RadarAction.ChangeCalculatorOffset -> {
|
|
||||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
|
||||||
transponders.find { it.uuid == uuid }?.catnum
|
|
||||||
}
|
|
||||||
if (catnum != null) {
|
|
||||||
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,32 +344,13 @@ class RadarViewModel(
|
|||||||
|
|
||||||
private fun initSstvDecoder() {
|
private fun initSstvDecoder() {
|
||||||
if (sstvDecoder == null) {
|
if (sstvDecoder == null) {
|
||||||
val decoder = SstvDecoder(
|
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
|
||||||
sampleRate = audioCapture.sampleRate,
|
|
||||||
targetRmsLevel = SSTV_TARGET_RMS,
|
|
||||||
includeScopeData = SSTV_INCLUDE_SCOPE,
|
|
||||||
enableRmsNormalization = SSTV_ENABLE_RMS_NORMALIZATION,
|
|
||||||
preFilterCutoffHz = SSTV_PREFILTER_CUTOFF_HZ,
|
|
||||||
enablePreFilter = SSTV_ENABLE_PREFILTER,
|
|
||||||
enableDiagnosticsHandle = SSTV_ENABLE_DIAGNOSTICS_HANDLE,
|
|
||||||
lineRecoveryStrategy = SSTV_LINE_RECOVERY_STRATEGY
|
|
||||||
)
|
|
||||||
sstvDecoder = decoder
|
sstvDecoder = decoder
|
||||||
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
decoder.frames.collect { frame ->
|
decoder.frames.collect { frame ->
|
||||||
val metrics = decoder.getQualityMetrics()
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
|
||||||
_uiState.update {
|
|
||||||
it.copy(sstv = it.sstv.copy(currentFrame = frame, diagnosticsMetrics = metrics))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
decoder.getDiagnosticsHandle()?.let { handle ->
|
|
||||||
viewModelScope.launch {
|
|
||||||
handle.metrics.collectLatest { metrics ->
|
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(diagnosticsMetrics = metrics)) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,39 +374,6 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// SSTV Decoder Tuning Parameters
|
|
||||||
// ==============================
|
|
||||||
// SSTV_TARGET_RMS: Normalized signal level before FM demodulation.
|
|
||||||
// - 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.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
|
|
||||||
|
|
||||||
// High-pass pre-filter to remove DC offset and subsonic noise before RMS normalization.
|
|
||||||
// Improves weak signal robustness by cleaning the noise floor estimate.
|
|
||||||
private const val SSTV_PREFILTER_CUTOFF_HZ = 500.0 // Removes everything below 500 Hz
|
|
||||||
|
|
||||||
// Enable pre-filtering. Set false to skip (minimal CPU cost if disabled).
|
|
||||||
private const val SSTV_ENABLE_PREFILTER = true
|
|
||||||
|
|
||||||
// Diagnostics handle can be enabled temporarily to capture field reports.
|
|
||||||
// Interpretation notes for noisy recordings:
|
|
||||||
// - syncHitRate < 0.30: weak/noisy signal path or mistuned gain
|
|
||||||
// - predictedLineBursts rising quickly: frequent sync loss and likely vertical collapse
|
|
||||||
// - maxPredictedStreak > 3 in Robot36 mode: noisy path and synthetic line flooding
|
|
||||||
private const val SSTV_ENABLE_DIAGNOSTICS_HANDLE = false
|
|
||||||
|
|
||||||
// Line recovery policy:
|
|
||||||
// - 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.Robot36Compatible
|
|
||||||
|
|
||||||
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
|
||||||
private const val SSTV_INCLUDE_SCOPE = false
|
|
||||||
|
|
||||||
val CTCSS_TONES = listOf(
|
val CTCSS_TONES = listOf(
|
||||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||||
@@ -490,6 +382,7 @@ class RadarViewModel(
|
|||||||
218.1, 225.7, 233.6, 241.8, 250.3
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
|
|||||||
@@ -53,12 +53,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SstvPage(
|
internal fun SstvPage(
|
||||||
@@ -95,44 +94,53 @@ internal fun SstvPage(
|
|||||||
|
|
||||||
if (showModeDialog.value) {
|
if (showModeDialog.value) {
|
||||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||||
val dismiss = { showModeDialog.value = false }
|
Dialog(onDismissRequest = { showModeDialog.value = false }) {
|
||||||
ConfirmDialog(
|
ElevatedCard {
|
||||||
title = "SSTV Mode",
|
Column(
|
||||||
onAccept = dismiss,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
onCancel = dismiss
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
Text(
|
||||||
modifier = Modifier
|
text = "SSTV Mode",
|
||||||
.fillMaxHeight(0.69f)
|
fontSize = 16.sp,
|
||||||
.background(MaterialTheme.colorScheme.background),
|
fontWeight = FontWeight.Medium,
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
color = MaterialTheme.colorScheme.primary,
|
||||||
) {
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
items(allModes) { mode ->
|
)
|
||||||
Row(
|
LazyColumn(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxHeight(0.5f)
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.background),
|
||||||
.clickable {
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
onAction(RadarAction.SstvSelectMode(mode))
|
|
||||||
showModeDialog.value = false
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Text(
|
items(allModes) { mode ->
|
||||||
text = mode,
|
Row(
|
||||||
fontSize = 16.sp,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontWeight = FontWeight.Medium,
|
modifier = Modifier
|
||||||
maxLines = 1,
|
.fillMaxWidth()
|
||||||
overflow = TextOverflow.Ellipsis,
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
modifier = Modifier
|
.clickable {
|
||||||
.weight(1f)
|
onAction(RadarAction.SstvSelectMode(mode))
|
||||||
.padding(start = 16.dp)
|
showModeDialog.value = false
|
||||||
)
|
}
|
||||||
RadioButton(
|
) {
|
||||||
selected = mode == sstv.selectedMode,
|
Text(
|
||||||
onClick = null,
|
text = mode,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
fontSize = 16.sp,
|
||||||
)
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
RadioButton(
|
||||||
|
selected = mode == sstv.selectedMode,
|
||||||
|
onClick = null,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,19 +181,6 @@ internal fun SstvPage(
|
|||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val qualityText = frame?.let {
|
|
||||||
val syncPct = (it.syncHitRate * 100f).roundToInt()
|
|
||||||
val gainX10 = (it.appliedGain * 10f).roundToInt() / 10f
|
|
||||||
val inputRmsX1000 = (it.inputRms * 1000f).roundToInt() / 1000f
|
|
||||||
val scope = if (it.scopePixels != null) " | Scope ${it.scopeWidth}x${it.scopeHeight}" else ""
|
|
||||||
val complete = if (it.imageComplete) " | Complete" else ""
|
|
||||||
"RMS $inputRmsX1000 | Gain x$gainX10 | Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}$scope$complete"
|
|
||||||
} ?: sstv.diagnosticsMetrics?.let {
|
|
||||||
val syncPct = (it.syncHitRate * 100f).roundToInt()
|
|
||||||
"Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom control bar — dark, blends with the black canvas
|
// Bottom control bar — dark, blends with the black canvas
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -203,15 +198,6 @@ internal fun SstvPage(
|
|||||||
outlineColor = MaterialTheme.colorScheme.background,
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
)
|
)
|
||||||
if (qualityText != null) {
|
|
||||||
OutlinedText(
|
|
||||||
text = qualityText,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fillColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
outlineColor = MaterialTheme.colorScheme.background,
|
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|||||||
+7
-452
@@ -21,18 +21,14 @@ import androidx.compose.animation.AnimatedVisibility
|
|||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -43,23 +39,14 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -67,17 +54,12 @@ import androidx.compose.ui.draw.rotate
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
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 com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
@@ -122,73 +104,6 @@ fun TransceiversPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CalculatorPage(
|
|
||||||
transceivers: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
onAction: (RadarAction) -> Unit,
|
|
||||||
calculatorOffsetKHz: String = "",
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val calculatorTransceivers = remember(transceivers) {
|
|
||||||
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
|
||||||
.let(DopplerFrequencyCalculator::deduplicateTransponders)
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
DopplerFrequencyCalculator(
|
|
||||||
transponder = selectedTransceiver,
|
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
offsetKHz = calculatorOffsetKHz,
|
|
||||||
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
@@ -257,8 +172,12 @@ private fun TransceiverItem(
|
|||||||
.rotate(if (isExpanded) 270f else 90f)
|
.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(
|
||||||
text = transceiverTitle(radio),
|
text = fullTitle,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -450,11 +369,10 @@ private fun ExpandedRadioControl(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
maxItemsInEachRow = 5,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
val chipModifier = Modifier.weight(1f)
|
val chipModifier = Modifier.width(64.dp)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioControl.ctcssTone == null,
|
selected = radioControl.ctcssTone == null,
|
||||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
@@ -518,362 +436,6 @@ private fun ExpandedRadioControl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class EditedField { TX, PASSBAND, RX }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DopplerFrequencyCalculator(
|
|
||||||
transponder: SatRadio,
|
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
offsetKHz: String = "",
|
|
||||||
onOffsetChange: (String) -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
|
||||||
|
|
||||||
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
|
|
||||||
var txFrequencyHz by remember { mutableStateOf(0L) }
|
|
||||||
var rxFrequencyHz by remember { mutableStateOf(0L) }
|
|
||||||
var passbandPosition by remember { mutableStateOf(0.5f) }
|
|
||||||
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
|
||||||
|
|
||||||
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
|
||||||
|
|
||||||
val txLow = transponder.uplinkLow ?: return
|
|
||||||
val txHigh = transponder.uplinkHigh ?: return
|
|
||||||
val rxLow = transponder.downlinkLow ?: return
|
|
||||||
val rxHigh = transponder.downlinkHigh ?: return
|
|
||||||
val txRange = txHigh - txLow
|
|
||||||
val rxRange = rxHigh - rxLow
|
|
||||||
if (txRange <= 0L || rxRange <= 0L) return
|
|
||||||
|
|
||||||
// 原始转发器值(逆转多普勒),用于 passband 映射计算
|
|
||||||
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
|
|
||||||
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
|
|
||||||
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
|
|
||||||
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
|
|
||||||
val rawTxRange = rawTxHigh - rawTxLow
|
|
||||||
val rawRxRange = rawRxHigh - rawRxLow
|
|
||||||
val rawTransponder = transponder.copy(
|
|
||||||
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
|
|
||||||
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
|
|
||||||
)
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (txFrequencyHz == 0L) {
|
|
||||||
txFrequencyHz = txLow + txRange / 2
|
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: (rxLow + rxRange / 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
|
|
||||||
LaunchedEffect(orbitalPos) {
|
|
||||||
if (lastEditedField == EditedField.TX) {
|
|
||||||
// TX 是锚点,不变;重算 RX
|
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: rxFrequencyHz
|
|
||||||
} else if (lastEditedField == EditedField.RX) {
|
|
||||||
// RX 是锚点,不变;重算 TX
|
|
||||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
|
||||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: txFrequencyHz
|
|
||||||
} else if (lastEditedField == EditedField.PASSBAND) {
|
|
||||||
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
|
|
||||||
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: rxFrequencyHz
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
|
|
||||||
passbandPosition
|
|
||||||
} else {
|
|
||||||
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
|
|
||||||
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
|
||||||
} else {
|
|
||||||
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateTx(newTxHz: Long) {
|
|
||||||
when (lastEditedField) {
|
|
||||||
EditedField.PASSBAND -> {
|
|
||||||
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
|
|
||||||
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
|
||||||
passbandPosition = pos
|
|
||||||
txFrequencyHz = txLow + (pos * txRange).toLong()
|
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: rxFrequencyHz
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
|
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: rxFrequencyHz
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateRx(newRxHz: Long) {
|
|
||||||
when (lastEditedField) {
|
|
||||||
EditedField.PASSBAND -> {
|
|
||||||
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
|
|
||||||
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
|
||||||
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
|
|
||||||
passbandPosition = if (txFromRx != null) {
|
|
||||||
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
|
|
||||||
} else pos
|
|
||||||
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: rxFrequencyHz
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
|
|
||||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
|
||||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: txFrequencyHz
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
// 左半边: TX + RX 按钮
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
FilterChip(
|
|
||||||
selected = lastEditedField == EditedField.TX,
|
|
||||||
onClick = {
|
|
||||||
when (lastEditedField) {
|
|
||||||
EditedField.TX -> {
|
|
||||||
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
|
||||||
lastEditedField = EditedField.PASSBAND
|
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: rxFrequencyHz
|
|
||||||
}
|
|
||||||
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
|
|
||||||
else -> { lastEditedField = EditedField.TX }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight()
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = lastEditedField == EditedField.RX,
|
|
||||||
onClick = {
|
|
||||||
when (lastEditedField) {
|
|
||||||
EditedField.RX -> {
|
|
||||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
|
||||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: txFrequencyHz
|
|
||||||
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
|
||||||
lastEditedField = EditedField.PASSBAND
|
|
||||||
}
|
|
||||||
EditedField.PASSBAND -> {
|
|
||||||
lastEditedField = EditedField.RX
|
|
||||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
|
||||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
|
||||||
) ?: txFrequencyHz
|
|
||||||
}
|
|
||||||
else -> { lastEditedField = EditedField.RX }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 右半边: Offset 输入框(带细线边框)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(48.dp)
|
|
||||||
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = offsetKHz,
|
|
||||||
onValueChange = onOffsetChange,
|
|
||||||
singleLine = true,
|
|
||||||
textStyle = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
if (offsetKHz.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "Offset (kHz)",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TX 行
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
|
|
||||||
modifier = Modifier.width(32.dp).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
shape = MaterialTheme.shapes.extraSmall
|
|
||||||
) { Text("−", fontSize = 14.sp) }
|
|
||||||
|
|
||||||
Slider(
|
|
||||||
value = txSliderValue,
|
|
||||||
onValueChange = { norm ->
|
|
||||||
val rawFreq = txLow + (txRange * norm).toLong()
|
|
||||||
val snapped = ((rawFreq + 500) / 1000L) * 1000L
|
|
||||||
updateTx(snapped.coerceIn(txLow, txHigh))
|
|
||||||
},
|
|
||||||
valueRange = 0f..1f,
|
|
||||||
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
|
|
||||||
modifier = Modifier.width(32.dp).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
shape = MaterialTheme.shapes.extraSmall
|
|
||||||
) { Text("+", fontSize = 14.sp) }
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = " MHz",
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textAlign = TextAlign.End
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 0.5.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
|
||||||
)
|
|
||||||
|
|
||||||
// RX 行
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
|
|
||||||
modifier = Modifier.width(32.dp).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
shape = MaterialTheme.shapes.extraSmall
|
|
||||||
) { Text("−", fontSize = 14.sp) }
|
|
||||||
|
|
||||||
Slider(
|
|
||||||
value = rxSliderValue,
|
|
||||||
onValueChange = { norm ->
|
|
||||||
val rawFreq = rxLow + (rxRange * norm).toLong()
|
|
||||||
val snapped = ((rawFreq + 500) / 1000L) * 1000L
|
|
||||||
updateRx(snapped.coerceIn(rxLow, rxHigh))
|
|
||||||
},
|
|
||||||
valueRange = 0f..1f,
|
|
||||||
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
|
|
||||||
modifier = Modifier.width(32.dp).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
shape = MaterialTheme.shapes.extraSmall
|
|
||||||
) { Text("+", fontSize = 14.sp) }
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = " MHz",
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textAlign = TextAlign.End
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||||
val text = frequency?.let {
|
val text = frequency?.let {
|
||||||
@@ -889,12 +451,5 @@ 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 =
|
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")
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
|
|
||||||
+11
-11
@@ -39,27 +39,27 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MultiModesDialogPreview() {
|
private fun MultiTypesDialogPreview() {
|
||||||
val modes = listOf("FM", "APT", "SSTV", "BPSK")
|
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
|
||||||
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
|
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MultiModesDialog(
|
internal fun MultiTypesDialog(
|
||||||
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val selected = remember { mutableStateOf(modes.toSet()) }
|
val selected = remember { mutableStateOf(types.toSet()) }
|
||||||
val toggle = { mode: String ->
|
val toggle = { type: String ->
|
||||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
selected.value = if (type in selected.value) selected.value - type else selected.value + type
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.value.toList()) }
|
val onAccept = { accept(selected.value.toList()) }
|
||||||
ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(240.dp),
|
columns = GridCells.Adaptive(240.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -68,7 +68,7 @@ internal fun MultiModesDialog(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allModes) { index, item ->
|
itemsIndexed(allTypes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+20
-74
@@ -63,7 +63,6 @@ import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
|||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
@@ -88,27 +87,19 @@ private fun SatellitesScreen(
|
|||||||
navigateUp: () -> Unit
|
navigateUp: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isDialogShown) {
|
if (uiState.isDialogShown) {
|
||||||
MultiModesDialog(
|
MultiTypesDialog(
|
||||||
allModes = uiState.modesList,
|
allTypes = uiState.typesList,
|
||||||
modes = uiState.currentModes,
|
types = uiState.currentTypes,
|
||||||
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
|
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
|
||||||
accept = { onAction(SatellitesAction.SelectModes(it)) }
|
accept = { onAction(SatellitesAction.SelectTypes(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWarning) {
|
if (uiState.shouldSeeWarning) {
|
||||||
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
|
||||||
InfoDialog(
|
InfoDialog(
|
||||||
title = stringResource(R.string.sat_warning_title),
|
stringResource(R.string.sat_warning_title),
|
||||||
onDismiss = dismiss,
|
stringResource(R.string.sat_warning_message)
|
||||||
onAccept = dismiss
|
|
||||||
) {
|
) {
|
||||||
Text(
|
onAction(SatellitesAction.DismissWarning)
|
||||||
text = stringResource(R.string.sat_warning_message),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +114,9 @@ private fun SatellitesScreen(
|
|||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
if (isVerticalLayout()) {
|
if (isVerticalLayout()) {
|
||||||
TopBar {
|
TopBar {
|
||||||
ModeCard(
|
TypeCard(
|
||||||
modes = uiState.currentModes,
|
types = uiState.currentTypes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(
|
PrimaryIconCard(
|
||||||
@@ -157,9 +148,9 @@ private fun SatellitesScreen(
|
|||||||
resId = R.drawable.ic_done,
|
resId = R.drawable.ic_done,
|
||||||
modifier = Modifier.semantics { contentDescription = primCardCd }
|
modifier = Modifier.semantics { contentDescription = primCardCd }
|
||||||
)
|
)
|
||||||
ModeCard(
|
TypeCard(
|
||||||
modes = uiState.currentModes,
|
types = uiState.currentTypes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
SearchBar(
|
SearchBar(
|
||||||
@@ -241,8 +232,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -252,12 +243,12 @@ private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifie
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_radios),
|
painter = painterResource(id = R.drawable.ic_tags),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sat_type_hint, modesText),
|
text = stringResource(R.string.sat_type_hint, typesText),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -323,54 +314,9 @@ private fun SatellitesPreview() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
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)) {
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
||||||
stickyHeader(key = "sat_summary_header") {
|
items(items = items, key = { item -> item.catnum }) { entry ->
|
||||||
SummaryHeader(
|
Satellite(entry, onSelected, Modifier.animateItem())
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
+4
-4
@@ -24,8 +24,8 @@ data class SatellitesState(
|
|||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val shouldSeeWarning: Boolean = false,
|
val shouldSeeWarning: Boolean = false,
|
||||||
val itemsList: List<SatItem> = emptyList(),
|
val itemsList: List<SatItem> = emptyList(),
|
||||||
val currentModes: List<String> = emptyList(),
|
val currentTypes: List<String> = emptyList(),
|
||||||
val modesList: List<String> = emptyList()
|
val typesList: List<String> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SatellitesAction {
|
sealed interface SatellitesAction {
|
||||||
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
|
|||||||
data class SearchFor(val query: String) : SatellitesAction
|
data class SearchFor(val query: String) : SatellitesAction
|
||||||
data object SelectAll : SatellitesAction
|
data object SelectAll : SatellitesAction
|
||||||
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
||||||
data class SelectModes(val modes: List<String>) : SatellitesAction
|
data class SelectTypes(val types: List<String>) : SatellitesAction
|
||||||
data object ToggleModesDialog : SatellitesAction
|
data object ToggleTypesDialog : SatellitesAction
|
||||||
data object UnselectAll : SatellitesAction
|
data object UnselectAll : SatellitesAction
|
||||||
}
|
}
|
||||||
+10
-15
@@ -35,13 +35,13 @@ class SatellitesViewModel(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val defaultModes = selectionRepo.getCurrentModes()
|
private val defaultTypes = selectionRepo.getCurrentTypes()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
SatellitesState(
|
SatellitesState(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
||||||
currentModes = defaultModes,
|
currentTypes = defaultTypes,
|
||||||
modesList = selectionRepo.getModesList()
|
typesList = selectionRepo.getTypesList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val uiState: StateFlow<SatellitesState> = _uiState
|
val uiState: StateFlow<SatellitesState> = _uiState
|
||||||
@@ -49,7 +49,7 @@ class SatellitesViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
selectionRepo.setQuery(String())
|
selectionRepo.setQuery(String())
|
||||||
selectionRepo.setModes(defaultModes)
|
selectionRepo.setTypes(defaultTypes)
|
||||||
selectionRepo.getEntriesFlow().collectLatest { items ->
|
selectionRepo.getEntriesFlow().collectLatest { items ->
|
||||||
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
||||||
}
|
}
|
||||||
@@ -59,11 +59,6 @@ class SatellitesViewModel(
|
|||||||
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.selectedSatModes.collectLatest { modes ->
|
|
||||||
_uiState.update { it.copy(currentModes = modes) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: SatellitesAction) {
|
fun onAction(action: SatellitesAction) {
|
||||||
@@ -73,8 +68,8 @@ class SatellitesViewModel(
|
|||||||
is SatellitesAction.SearchFor -> searchFor(action.query)
|
is SatellitesAction.SearchFor -> searchFor(action.query)
|
||||||
SatellitesAction.SelectAll -> selectAll(true)
|
SatellitesAction.SelectAll -> selectAll(true)
|
||||||
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
||||||
is SatellitesAction.SelectModes -> selectModes(action.modes)
|
is SatellitesAction.SelectTypes -> selectTypes(action.types)
|
||||||
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
|
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
|
||||||
SatellitesAction.UnselectAll -> selectAll(false)
|
SatellitesAction.UnselectAll -> selectAll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,12 +86,12 @@ class SatellitesViewModel(
|
|||||||
selectionRepo.setSelection(listOf(id), isTicked.not())
|
selectionRepo.setSelection(listOf(id), isTicked.not())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectModes(modes: List<String>) = viewModelScope.launch {
|
private fun selectTypes(types: List<String>) = viewModelScope.launch {
|
||||||
selectionRepo.setModes(modes)
|
selectionRepo.setTypes(types)
|
||||||
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
|
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleModesDialog() {
|
private fun toggleTypesDialog() {
|
||||||
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+153
-666
File diff suppressed because it is too large.
Load diff
+154
-132
@@ -25,22 +25,23 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -59,12 +60,13 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
@@ -75,7 +77,6 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
|||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -118,23 +119,23 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
if (dialogs.dataSources) {
|
if (dialogs.dataSources) {
|
||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
|
||||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
||||||
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
||||||
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
||||||
statusCodes = uiState.dataSourcesStatus,
|
|
||||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||||
onDismiss = { dialogs.dataSources = false },
|
onDismiss = { dialogs.dataSources = false },
|
||||||
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
|
||||||
val newSettings = DataSourcesSettings(
|
val current = uiState.dataSourcesSettings
|
||||||
satelliteUrls = satUrls,
|
val newSettings = current.copy(
|
||||||
transceiversUrls = txUrls,
|
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
|
||||||
satelliteEnabled = satEnabled,
|
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
|
||||||
transceiversEnabled = txEnabled
|
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
|
||||||
|
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
||||||
)
|
)
|
||||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
onAction(SettingsAction.UpdateFromWeb)
|
if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,15 +143,14 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
NetworkOutputDialog(
|
NetworkOutputDialog(
|
||||||
initialSettings = uiState.rcSettings,
|
initialSettings = uiState.rcSettings,
|
||||||
onDismiss = { dialogs.network = false },
|
onDismiss = { dialogs.network = false },
|
||||||
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
|
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
|
||||||
onAction(
|
onAction(
|
||||||
SettingsAction.UpdateRC(
|
SettingsAction.UpdateRC(
|
||||||
uiState.rcSettings.copy(
|
uiState.rcSettings.copy(
|
||||||
rotatorState = rotState, rotatorAddress = rotAddr,
|
rotatorState = rotState, rotatorAddress = rotAddr,
|
||||||
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
||||||
frequencyState = freqState, frequencyAddress = freqAddr,
|
frequencyState = freqState, frequencyAddress = freqAddr,
|
||||||
frequencyPort = freqPort, frequencyFormat = freqFmt,
|
frequencyPort = freqPort, frequencyFormat = freqFmt
|
||||||
frequencyOffsetHz = freqOffsetHz
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -177,14 +177,10 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
if (dialogs.radioControl) {
|
if (dialogs.radioControl) {
|
||||||
RadioControlDialog(
|
RadioControlDialog(
|
||||||
initialSettings = uiState.radioControlSettings,
|
initialSettings = uiState.radioControlSettings,
|
||||||
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
|
|
||||||
onDismiss = { dialogs.radioControl = false },
|
onDismiss = { dialogs.radioControl = false },
|
||||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (dialogs.whatsNew) {
|
|
||||||
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// URLs for top bar
|
// URLs for top bar
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
@@ -205,11 +201,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
if (isVerticalLayout) {
|
if (isVerticalLayout) {
|
||||||
TopBar {
|
TopBar {
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { dialogs.whatsNew = true },
|
onClick = { safeOpenUri(appUrl) },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
}
|
}
|
||||||
TopBar {
|
TopBar {
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -231,9 +227,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { dialogs.whatsNew = true },
|
onClick = { safeOpenUri(appUrl) },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -289,22 +285,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onRadioControlClick = { dialogs.radioControl = true }
|
onRadioControlClick = { dialogs.radioControl = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item { OtherCard(uiState.otherSettings, onAction) }
|
||||||
if (isVerticalLayout) {
|
item { MapSettingsCard(uiState.otherSettings, onAction) }
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
item { CardCredits() }
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,7 +364,7 @@ private fun LocationCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DataCardPreview() = MainTheme {
|
private fun DataCardPreview() = MainTheme {
|
||||||
val settings = DataSettings(true, 5000, 2500, 0L)
|
val settings = DataSettings(true, 5000, 2500, 0L)
|
||||||
DataCard(settings = settings, updateFromWeb = {}, clearAllData = {}, showDataSourcesDialog = {})
|
DataCard(settings = settings, {}, {}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -483,81 +466,138 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
shouldSeeWarning = false,
|
shouldSeeWarning = false,
|
||||||
shouldSeeWhatsNew = false
|
shouldSeeWhatsNew = false
|
||||||
)
|
)
|
||||||
OtherCard(settings = values, onAction = {})
|
OtherCard(settings = values) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
|
private fun MapSettingsCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
var keyValue by remember(settings.tiandituKey) { mutableStateOf(settings.tiandituKey) }
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
LaunchedEffect(keyValue) {
|
||||||
|
kotlinx.coroutines.delay(400)
|
||||||
|
if (keyValue.trim() != settings.tiandituKey) {
|
||||||
|
onAction(SettingsAction.SetTiandituKey(keyValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_other_title),
|
text = stringResource(id = R.string.prefs_map_title),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
// Display preferences: UTC clock + night filter
|
val currentMapSource = MapSource.normalize(settings.mapSource)
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
MapSourceRow(
|
||||||
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
currentMapSource = currentMapSource,
|
||||||
onAction(SettingsAction.ToggleUtc(it))
|
firstSource = MapSource.OSM,
|
||||||
}
|
firstTitle = stringResource(id = R.string.prefs_map_source_osm),
|
||||||
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
secondSource = MapSource.TIANDITU_VECTOR,
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
secondTitle = stringResource(id = R.string.prefs_map_source_tianditu_vector),
|
||||||
}
|
onAction = onAction
|
||||||
}
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
// Radar behavior: sweep animation + sensor control
|
MapSourceRow(
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
currentMapSource = currentMapSource,
|
||||||
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
firstSource = MapSource.TIANDITU_IMAGE,
|
||||||
onAction(SettingsAction.ToggleSweep(it))
|
firstTitle = stringResource(id = R.string.prefs_map_source_tianditu_image),
|
||||||
}
|
onAction = onAction
|
||||||
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
)
|
||||||
onAction(SettingsAction.ToggleSensor(it))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
OutlinedTextField(
|
||||||
}
|
value = keyValue,
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
onValueChange = { keyValue = it },
|
||||||
// Data management (full width)
|
label = { Text(text = stringResource(id = R.string.prefs_map_tianditu_key)) },
|
||||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
singleLine = true,
|
||||||
onAction(SettingsAction.ToggleUpdate(it))
|
modifier = Modifier.fillMaxWidth()
|
||||||
}
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
// Compass calibration sliders at the bottom
|
CardButton(
|
||||||
CompassOffsetRow(
|
onClick = { onAction(SettingsAction.SetTiandituKey(keyValue)) },
|
||||||
labelResId = R.string.prefs_other_compass_offset,
|
text = stringResource(id = R.string.prefs_map_save_key),
|
||||||
value = settings.radarCompassOffset,
|
modifier = Modifier.fillMaxWidth()
|
||||||
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
|
@Composable
|
||||||
private fun CompassOffsetRow(
|
private fun MapSourceRow(
|
||||||
labelResId: Int,
|
currentMapSource: String,
|
||||||
value: Float,
|
firstSource: String,
|
||||||
range: ClosedFloatingPointRange<Float>,
|
firstTitle: String,
|
||||||
onValueChange: (Float) -> Unit
|
secondSource: String? = null,
|
||||||
|
secondTitle: String? = null,
|
||||||
|
onAction: (SettingsAction) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
MapSourceButton(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onClick = { onAction(SettingsAction.SetMapSource(firstSource)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
text = firstTitle,
|
||||||
) {
|
selected = currentMapSource == firstSource,
|
||||||
Text(text = stringResource(id = labelResId))
|
modifier = Modifier.weight(1f)
|
||||||
Text(text = "${value.toInt()}°")
|
)
|
||||||
|
if (secondSource != null && secondTitle != null) {
|
||||||
|
MapSourceButton(
|
||||||
|
onClick = { onAction(SettingsAction.SetMapSource(secondSource)) },
|
||||||
|
text = secondTitle,
|
||||||
|
selected = currentMapSource == secondSource,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapSourceButton(onClick: () -> Unit, text: String, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
ElevatedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (selected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||||
|
ElevatedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(268.dp)
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
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))
|
|
||||||
Slider(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
valueRange = range
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -572,21 +612,6 @@ 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
|
@Composable
|
||||||
private fun formatUpdateTime(updateTime: Long): String {
|
private fun formatUpdateTime(updateTime: Long): String {
|
||||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||||
@@ -621,6 +646,7 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
|||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.height(268.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -654,7 +680,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_sputnik),
|
painter = painterResource(id = R.drawable.ic_satellites),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -705,7 +731,6 @@ private class DialogVisibility {
|
|||||||
var network by mutableStateOf(false)
|
var network by mutableStateOf(false)
|
||||||
var bluetooth by mutableStateOf(false)
|
var bluetooth by mutableStateOf(false)
|
||||||
var radioControl by mutableStateOf(false)
|
var radioControl by mutableStateOf(false)
|
||||||
var whatsNew by mutableStateOf(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -713,12 +738,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
return rememberSaveable(saver = run {
|
return rememberSaveable(saver = run {
|
||||||
androidx.compose.runtime.saveable.Saver(
|
androidx.compose.runtime.saveable.Saver(
|
||||||
save = {
|
save = {
|
||||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
|
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
|
||||||
},
|
},
|
||||||
restore = {
|
restore = {
|
||||||
DialogVisibility().apply {
|
DialogVisibility().apply {
|
||||||
position = it[0]; locator = it[1]; dataSources = it[2]
|
position = it[0]; locator = it[1]; dataSources = it[2]
|
||||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
|
network = it[3]; bluetooth = it[4]; radioControl = it[5]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -754,15 +779,12 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(locationError))
|
else sendAction(SettingsAction.ShowToast(locationError))
|
||||||
}
|
}
|
||||||
|
|
||||||
val satellitesImportError = stringResource(R.string.prefs_data_import_satellites_error)
|
|
||||||
val transceiversImportError = stringResource(R.string.prefs_data_import_transceivers_error)
|
|
||||||
|
|
||||||
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString(), satellitesImportError)) }
|
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString())) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString(), transceiversImportError)) }
|
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString())) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
|
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
|
||||||
|
|||||||
+6
-11
@@ -28,10 +28,7 @@ data class PositionSettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class DataSettings(
|
data class DataSettings(
|
||||||
val isUpdating: Boolean,
|
val isUpdating: Boolean, val entriesTotal: Int, val radiosTotal: Int, val timestamp: Long
|
||||||
val entriesTotal: Int,
|
|
||||||
val radiosTotal: Int,
|
|
||||||
val timestamp: Long
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SettingsState(
|
data class SettingsState(
|
||||||
@@ -41,9 +38,7 @@ data class SettingsState(
|
|||||||
val otherSettings: OtherSettings,
|
val otherSettings: OtherSettings,
|
||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
val radioControlSettings: RadioControlSettings,
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings,
|
val dataSourcesSettings: DataSourcesSettings
|
||||||
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
|
||||||
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -55,8 +50,8 @@ sealed interface SettingsAction {
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
data object UpdateFromWeb : SettingsAction
|
data object UpdateFromWeb : SettingsAction
|
||||||
data class UpdateTLEFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
|
data class UpdateTLEFromFile(val uri: String) : SettingsAction
|
||||||
data class UpdateTransceiversFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
|
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction
|
||||||
data object ClearAllData : SettingsAction
|
data object ClearAllData : SettingsAction
|
||||||
|
|
||||||
// Toggles
|
// Toggles
|
||||||
@@ -66,8 +61,8 @@ sealed interface SettingsAction {
|
|||||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||||
data class SetRadarCompassOffset(val value: Float) : SettingsAction
|
data class SetMapSource(val value: String) : SettingsAction
|
||||||
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
|
data class SetTiandituKey(val value: String) : SettingsAction
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+13
-36
@@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val container: IMainContainer,
|
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
@@ -48,9 +47,7 @@ class SettingsViewModel(
|
|||||||
otherSettings = settingsRepo.otherSettings.value,
|
otherSettings = settingsRepo.otherSettings.value,
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
|
||||||
pairedBluetoothDevices = container.providePairedBluetoothDevices()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,11 +65,11 @@ class SettingsViewModel(
|
|||||||
settingsRepo.databaseState.collect { state ->
|
settingsRepo.databaseState.collect { state ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
dataSettings = it.dataSettings.copy(
|
dataSettings = DataSettings(
|
||||||
isUpdating = false,
|
false,
|
||||||
entriesTotal = state.numberOfSatellites,
|
state.numberOfSatellites,
|
||||||
radiosTotal = state.numberOfRadios,
|
state.numberOfRadios,
|
||||||
timestamp = state.updateTimestamp
|
state.updateTimestamp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,11 +90,6 @@ class SettingsViewModel(
|
|||||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.dataSourcesStatus.collect { status ->
|
|
||||||
_uiState.update { it.copy(dataSourcesStatus = status) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.radioControlSettings.collect { settings ->
|
settingsRepo.radioControlSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||||
@@ -115,11 +107,11 @@ class SettingsViewModel(
|
|||||||
SettingsAction.DismissPosMessages -> dismissPosMessage()
|
SettingsAction.DismissPosMessages -> dismissPosMessage()
|
||||||
// Data
|
// Data
|
||||||
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
|
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
|
||||||
is SettingsAction.UpdateTLEFromFile -> runManualImport(action.invalidFileMessage) {
|
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
|
||||||
databaseRepo.updateTLEFromFile(action.uri)
|
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
|
||||||
}
|
databaseRepo.updateTransceiversFromFile(
|
||||||
is SettingsAction.UpdateTransceiversFromFile -> runManualImport(action.invalidFileMessage) {
|
action.uri
|
||||||
databaseRepo.updateTransceiversFromFile(action.uri)
|
)
|
||||||
}
|
}
|
||||||
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
|
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
|
||||||
// Toggles
|
// Toggles
|
||||||
@@ -129,8 +121,8 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
is SettingsAction.SetMapSource -> settingsRepo.updateOtherSettings { it.copy(mapSource = action.value) }
|
||||||
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
is SettingsAction.SetTiandituKey -> settingsRepo.updateOtherSettings { it.copy(tiandituKey = action.value.trim()) }
|
||||||
// Remote control & data sources
|
// Remote control & data sources
|
||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
@@ -194,27 +186,12 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runManualImport(importErrorMessage: String, block: suspend () -> Int) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
_uiState.update { it.copy(dataSettings = it.dataSettings.copy(isUpdating = true)) }
|
|
||||||
if (block() == 0) showToast(importErrorMessage)
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
_uiState.update { it.copy(dataSettings = it.dataSettings.copy(isUpdating = false)) }
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
container = container,
|
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast()
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
package com.rtbishop.look4sat.feature.status
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
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.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
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.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.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
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.IContainerProvider
|
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
|
||||||
private val TILE_WIDTH: Dp = 64.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
|
|
||||||
MaterialTheme.colorScheme.tertiaryContainer
|
|
||||||
statusText.contains("Not Heard", ignoreCase = true) ->
|
|
||||||
Color(0xFFDC267F)
|
|
||||||
else ->
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Localize AMSAT API status text for the current locale; falls back to the raw text. */
|
|
||||||
@Composable
|
|
||||||
private fun statusTextOf(statusText: String): String {
|
|
||||||
if (Locale.getDefault().language != Locale.CHINESE.language) return statusText
|
|
||||||
return when (statusText.lowercase()) {
|
|
||||||
"heard" -> stringResource(R.string.amsat_status_heard)
|
|
||||||
"telemetry only" -> stringResource(R.string.amsat_status_tlm_only)
|
|
||||||
"not heard" -> stringResource(R.string.amsat_status_not_heard)
|
|
||||||
"crew active" -> stringResource(R.string.amsat_status_crew_active)
|
|
||||||
else -> statusText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SatStatusDestination() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
|
||||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
SatStatusScreen(uiState) { viewModel.refresh() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
|
||||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
|
||||||
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
|
||||||
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = 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 = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
HeaderRow(statuses = uiState.statuses)
|
|
||||||
HorizontalDivider(thickness = 1.dp)
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
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) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.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 = 14.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(32.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.clickable(onClick = onRefresh),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (isRefreshing) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
|
||||||
} else {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.ic_refresh),
|
|
||||||
contentDescription = stringResource(id = R.string.amsat_refresh),
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
|
|
||||||
@Composable
|
|
||||||
private fun LegendRow() {
|
|
||||||
val legend = listOf(
|
|
||||||
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
|
|
||||||
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
|
|
||||||
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
|
||||||
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
|
|
||||||
)
|
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
legend.forEach { (label, color) ->
|
|
||||||
val alphaColor = color.copy(alpha = 0.25f)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(32.dp))
|
|
||||||
.background(alphaColor)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.background(color)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
|
|
||||||
@Composable
|
|
||||||
private fun HeaderRow(statuses: List<SatStatus>) {
|
|
||||||
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.amsat_name),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.weight(1f).padding(start = 4.dp)
|
|
||||||
)
|
|
||||||
dates.forEach { date ->
|
|
||||||
Text(
|
|
||||||
text = date,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.width(TILE_WIDTH)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
|
|
||||||
@Composable
|
|
||||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
|
||||||
val noReportGray = 0xFFC0C0C0L
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = status.name,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
|
||||||
)
|
|
||||||
status.days.forEach { day ->
|
|
||||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
|
||||||
DayCell(
|
|
||||||
slot = slot,
|
|
||||||
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.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(28.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.background(color)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (slot.count > 0) {
|
|
||||||
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Report detail dialog (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] }
|
|
||||||
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
|
|
||||||
if (dayReports.isEmpty()) {
|
|
||||||
Text(stringResource(id = R.string.amsat_no_reports))
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
} else {
|
|
||||||
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
|
||||||
items(dayReports) { report ->
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.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 = statusTextOf(report.statusText), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
|
||||||
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider(thickness = 0.5.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
||||||
|
|
||||||
private fun formatFetchedAt(utcMs: Long): String {
|
|
||||||
val cal = Calendar.getInstance()
|
|
||||||
cal.timeInMillis = utcMs
|
|
||||||
val day = cal.get(Calendar.DAY_OF_MONTH)
|
|
||||||
val monthIndex = cal.get(Calendar.MONTH)
|
|
||||||
val year = cal.get(Calendar.YEAR)
|
|
||||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
|
||||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
|
||||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
|
||||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
|
||||||
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
|
|
||||||
} else {
|
|
||||||
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
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.IAmSatRepository
|
|
||||||
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 amSatRepo: IAmSatRepository
|
|
||||||
) : 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 = 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 = 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.amSatRepo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-1
@@ -2,5 +2,7 @@
|
|||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
org.gradle.tooling.parallel=true
|
|
||||||
org.gradle.workers.max=4
|
org.gradle.workers.max=4
|
||||||
|
|
||||||
|
# Enabled parallel sync for Gradle 9.4+
|
||||||
|
org.gradle.tooling.parallel=true
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "446"
|
appVersionCode = "443"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.6"
|
appVersionName = "4.4.3"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -10,22 +10,22 @@ minSdk = "24"
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
jdkVersion = "21"
|
jdkVersion = "21"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "cn.bh2vsq.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.3.1"
|
android-gradle-plugin = "9.2.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.19.0"
|
androidx-core-ktx = "1.19.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.06.01"
|
compose-bom = "2026.06.00"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.11.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.5"
|
compose-navigation3 = "1.1.3"
|
||||||
|
|
||||||
google-ksp = "2.3.10"
|
google-ksp = "2.3.8"
|
||||||
|
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.0"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Tue Aug 11 14:16:53 CEST 2026
|
#Sat Jun 27 12:51:12 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
|
distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.6.1/gradle-9.6.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
+1
-2
@@ -25,6 +25,5 @@ include(
|
|||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings",
|
":feature:settings"
|
||||||
":feature:status"
|
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user