Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a4ceddcb0 | ||
|
|
50233cadc1 | ||
|
|
8d88100b15 | ||
|
|
59d6e2ae05 | ||
|
|
e2b5c6d673 | ||
|
|
da89d6426f | ||
|
|
c19209655b | ||
|
|
d456bdf4b5 | ||
|
|
f06b17a194 | ||
|
|
675194075e | ||
|
|
0ecccc4746 | ||
|
|
88d1498eac | ||
|
|
31ae04329d | ||
|
|
eae6304124 | ||
|
|
97c6d609ac | ||
|
|
44c442ddd3 | ||
|
|
f9250ec1f1 | ||
|
|
bff3e1af09 | ||
|
|
6dbe30b3f6 | ||
|
|
8a920acead | ||
|
|
929f652b8e | ||
|
|
610768501e | ||
|
|
427e6e1862 | ||
|
|
bfa5e5f474 | ||
|
|
213425379c | ||
|
|
c3444aed24 | ||
|
|
368bd199e5 | ||
|
|
2298d8eea4 | ||
|
|
7bb0bbbb46 | ||
|
|
deec5581ae | ||
|
|
1de5632428 | ||
|
|
2dfd76825b | ||
|
|
06fc8bc2fb | ||
|
|
75322390b8 | ||
|
|
d1312f1f09 | ||
|
|
75acbbd633 | ||
|
|
a37fb8f773 | ||
|
|
9d0d971500 | ||
|
|
1813e1bfa4 | ||
|
|
bd09ad22ff | ||
|
|
7e90071c4d | ||
|
|
d1cc944be2 | ||
|
|
fb28283679 | ||
|
|
81ae8772d2 | ||
|
|
571145838d | ||
|
|
017b178f2e | ||
|
|
670444858c | ||
|
|
7bdff5df81 | ||
|
|
df083840ac | ||
|
|
a0279bac17 | ||
|
|
3d630041e1 | ||
|
|
ea92d3c150 | ||
|
|
86ee3dcbac | ||
|
|
43788ba7a7 | ||
|
|
1a3f1bb34c | ||
|
|
998c98267b | ||
|
|
28fa373db0 | ||
|
|
4ca33c37ad | ||
|
|
bd42e643ea | ||
|
|
4baf3821e5 | ||
|
|
d7e1ce11c9 | ||
|
|
5de370983d | ||
|
|
f272e268ab | ||
|
|
e59507a4d3 | ||
|
|
0bae306312 | ||
|
|
65f61a5147 | ||
|
|
1064dc739e | ||
|
|
8aedd38b51 | ||
|
|
ad8f08a577 | ||
|
|
2afce54472 | ||
|
|
8e50fcf8fb | ||
|
|
e11b03d56b | ||
|
|
fa55b82a5a | ||
|
|
d57832b0d4 | ||
|
|
1d0e8921a9 | ||
|
|
23d95b8241 | ||
|
|
8af80866bc | ||
|
|
b9e5ff70f5 | ||
|
|
c176d6ad88 | ||
|
|
5a6b162e4f | ||
|
|
33712c29fc | ||
|
|
eac1e6e273 |
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"
|
|
||||||
@@ -4,10 +4,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- v**
|
- v**
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Release tag name (e.g. v4.4.3)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref_name }}
|
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -16,66 +21,40 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
- name: Assemble Artifacts
|
- name: Assemble APK
|
||||||
run: ./gradlew assembleRelease bundleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
|
VERSION=${TAG_NAME#v}
|
||||||
|
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
|
||||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
${BUILD_TOOLS}apksigner sign \
|
${BUILD_TOOLS}apksigner sign \
|
||||||
--ks keystore.jks \
|
--ks keystore.jks \
|
||||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
--out app/build/outputs/apk/release/look4sat.apk \
|
--out "$SIGNED_APK" \
|
||||||
"$APK"
|
"$APK"
|
||||||
rm keystore.jks
|
rm keystore.jks
|
||||||
|
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
||||||
- name: Sign Bundle
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
|
||||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
|
||||||
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
|
||||||
-keystore keystore.jks \
|
|
||||||
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
|
||||||
-keypass ${{ secrets.KEY_PASSWORD }} \
|
|
||||||
"$AAB" ${{ secrets.KEY_ALIAS }}
|
|
||||||
rm keystore.jks
|
|
||||||
|
|
||||||
- name: Setup Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: '3.4'
|
|
||||||
|
|
||||||
- name: Deploy Bundle to Google Play
|
|
||||||
run: |
|
|
||||||
gem install multi_json
|
|
||||||
gem install fastlane --no-document
|
|
||||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
|
||||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
|
||||||
fastlane supply \
|
|
||||||
--aab "$AAB" \
|
|
||||||
--json_key service_account.json \
|
|
||||||
--package_name com.rtbishop.look4sat \
|
|
||||||
--track production \
|
|
||||||
--skip_upload_images true \
|
|
||||||
--skip_upload_screenshots true \
|
|
||||||
rm service_account.json
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
gh release upload $TAG_NAME "$SIGNED_APK"
|
||||||
+4
-2
@@ -1,6 +1,9 @@
|
|||||||
# Built application files
|
# Built application files
|
||||||
*.ap_
|
*.ap_
|
||||||
|
|
||||||
|
# Hermes AI plans and metadata
|
||||||
|
.hermes/
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
# Files for the ART/Dalvik VM
|
||||||
*.dex
|
*.dex
|
||||||
|
|
||||||
@@ -36,8 +39,7 @@ captures/
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
# Uncomment the following line if you do not want to check your keystore files in.
|
*.jks
|
||||||
#*.jks
|
|
||||||
/*.properties
|
/*.properties
|
||||||
/keystore.properties
|
/keystore.properties
|
||||||
/app/keystore.jks
|
/app/keystore.jks
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load signing config from keystore.properties (gitignored, never commit credentials)
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propsFile = rootProject.file("keystore.properties")
|
||||||
|
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
signingConfigs {
|
||||||
|
if (keystoreProperties["storeFile"] != null) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.findByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -42,7 +43,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="com.rtbishop.look4sat.bg7nta" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -63,13 +63,16 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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.ViewModelStoreOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
@@ -78,8 +81,11 @@ import com.rtbishop.look4sat.core.presentation.Screen
|
|||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
|
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||||
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
|
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
@@ -125,12 +131,17 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
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 navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||||
|
val mutualViewModel: MutualViewModel = viewModel(
|
||||||
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
|
factory = MutualViewModel.factory(container)
|
||||||
|
)
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
@@ -145,6 +156,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
@@ -190,6 +202,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> {
|
||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
// navigateToRadar()
|
// navigateToRadar()
|
||||||
@@ -201,6 +214,20 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
entry<Screen.Map> {
|
entry<Screen.Map> {
|
||||||
MapDestination()
|
MapDestination()
|
||||||
}
|
}
|
||||||
|
entry<Screen.Mutual> {
|
||||||
|
MutualScreen(
|
||||||
|
viewModel = mutualViewModel,
|
||||||
|
navigateUp = navigateBack,
|
||||||
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.Roaming> {
|
||||||
|
RoamingScreen()
|
||||||
|
}
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
@@ -224,6 +251,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
.clickable {
|
.clickable {
|
||||||
val pass = trackingState.currentPass
|
val pass = trackingState.currentPass
|
||||||
if (pass != null) {
|
if (pass != null) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -32,8 +32,10 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
|
implementation(project(":feature:roaming"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
namespace = libs.versions.packageName.get()
|
namespace = libs.versions.packageName.get()
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = libs.versions.packageName.get()
|
applicationId = libs.versions.applicationId.get()
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
versionName = libs.versions.appVersionName.get()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+11
-10
@@ -29,7 +29,6 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
|||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
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
|
||||||
@@ -51,6 +50,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
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.repository.MutualPassData
|
||||||
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.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
@@ -62,24 +62,32 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.OkHttpClient
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||||
|
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||||
|
|
||||||
|
override fun setMutualPassData(data: MutualPassData) {
|
||||||
|
_mutualPassData.value = data
|
||||||
|
}
|
||||||
|
|
||||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||||
|
|
||||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||||
@@ -138,13 +146,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)
|
||||||
|
|||||||
-156
@@ -1,156 +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
|
|
||||||
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
|
|
||||||
|
|
||||||
val names = parseCatalog(catalogJson)
|
|
||||||
val reports = parseReports(reportsJson)
|
|
||||||
|
|
||||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
|
||||||
|
|
||||||
val statuses = buildStatuses(names, reports, nowSec)
|
|
||||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
|
||||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse catalog JSON to list of satellite names */
|
|
||||||
private fun parseCatalog(json: String): List<String> {
|
|
||||||
return try {
|
|
||||||
val arr = JSONObject(json).getJSONArray("data")
|
|
||||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse reports JSON to list of ApiReport domain objects */
|
|
||||||
private fun parseReports(json: String): List<ApiReport> {
|
|
||||||
return try {
|
|
||||||
val arr = JSONObject(json).getJSONArray("data")
|
|
||||||
(0 until arr.length()).mapNotNull { i ->
|
|
||||||
val o = arr.getJSONObject(i)
|
|
||||||
val iso = o.optString("reported_time", "")
|
|
||||||
if (iso.isEmpty()) null else ApiReport(
|
|
||||||
id = o.optString("id", ""),
|
|
||||||
name = o.optString("name", ""),
|
|
||||||
callsign = o.optString("callsign", ""),
|
|
||||||
report = o.optString("report", ""),
|
|
||||||
gridSquare = o.optString("grid_square", ""),
|
|
||||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
|
||||||
private fun parseIsoUtcSec(iso: String): Long {
|
|
||||||
return try {
|
|
||||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
|
||||||
} catch (e: Exception) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
|
|
||||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
|
||||||
val byName = reports.groupBy { it.name }
|
|
||||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
||||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
|
||||||
val labels = (0 until 6).map { d ->
|
|
||||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
|
||||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
|
||||||
}
|
|
||||||
return names.map { name ->
|
|
||||||
val slots = (0 until 72).map { slotIdx ->
|
|
||||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
|
||||||
val slotEnd = nowSec - slotIdx * 7200L
|
|
||||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
|
||||||
if (inSlot.isEmpty()) {
|
|
||||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
|
||||||
} else {
|
|
||||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
|
||||||
SatSlot(
|
|
||||||
statusColor = statusColorOf(newest.report),
|
|
||||||
count = inSlot.size,
|
|
||||||
reportIds = inSlot.map { it.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val days = (0 until 6).map { d ->
|
|
||||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
|
||||||
}
|
|
||||||
SatStatus(name = name, days = days)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toSatReport(r: ApiReport): SatReport {
|
|
||||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
|
||||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
|
||||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
|
||||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
|
||||||
val y = cal.get(Calendar.YEAR)
|
|
||||||
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
|
||||||
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
|
||||||
return SatReport(
|
|
||||||
id = r.id,
|
|
||||||
statusText = r.report,
|
|
||||||
call = r.callsign,
|
|
||||||
grid = r.gridSquare,
|
|
||||||
dateUtc = "$y-$mo-$d",
|
|
||||||
timeUtc = "$hh:$mm UTC"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map status text to color value (for UI rendering). */
|
|
||||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
|
||||||
"heard", "crew active" -> ACTIVE_BLUE
|
|
||||||
"telemetry only" -> TLM_ORANGE
|
|
||||||
"not heard" -> NOT_HEARD_PINK
|
|
||||||
else -> CONFLICT_DEEP_ORANGE
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
// AMSAT official status colors (from amsat.org/status)
|
|
||||||
private const val ACTIVE_BLUE = 0xFF648FFF
|
|
||||||
private const val TLM_ORANGE = 0xFFFFB000
|
|
||||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
|
||||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
|
||||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,6 +47,7 @@ class DatabaseRepo(
|
|||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||||
localSource.insertEntries(entries)
|
localSource.insertEntries(entries)
|
||||||
|
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||||
importedCount = entries.size
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -68,18 +69,24 @@ class DatabaseRepo(
|
|||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
val tleUrls = buildMap {
|
||||||
putAll(Sources.satelliteDataUrls)
|
putAll(Sources.satelliteDataUrls)
|
||||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
|
||||||
|
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||||
|
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||||
}.filterValues { it.isNotBlank() }
|
}.filterValues { it.isNotBlank() }
|
||||||
val radioUrls = buildMap {
|
val radioUrls = buildMap {
|
||||||
putAll(Sources.transceiversDataUrls)
|
putAll(Sources.transceiversDataUrls)
|
||||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||||
|
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||||
}.filterValues { it.isNotBlank() }
|
}.filterValues { it.isNotBlank() }
|
||||||
// launch all network requests concurrently
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
// parse fetched data concurrently
|
// parse fetched data concurrently and associate with types
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
|
|||||||
+22
-25
@@ -37,7 +37,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -74,7 +73,7 @@ class SatelliteRepo(
|
|||||||
aosStartMinute = settings.aosStartMinute,
|
aosStartMinute = settings.aosStartMinute,
|
||||||
aosEndMinute = settings.aosEndMinute,
|
aosEndMinute = settings.aosEndMinute,
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
modes = settingsRepo.selectedSatModes.value
|
modes = settings.selectedModes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,14 +95,8 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRadios(
|
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
|
||||||
sat: OrbitalObject,
|
|
||||||
pos: GeoPos,
|
|
||||||
radios: List<SatRadio>,
|
|
||||||
time: Long
|
|
||||||
): List<SatRadio> {
|
|
||||||
return withContext(dispatcher) {
|
return withContext(dispatcher) {
|
||||||
val satPos = sat.getPosition(pos, time)
|
|
||||||
radios.map { transmitter ->
|
radios.map { transmitter ->
|
||||||
transmitter.copy(
|
transmitter.copy(
|
||||||
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||||
@@ -159,7 +152,7 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -177,7 +170,7 @@ class SatelliteRepo(
|
|||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
aosMinute in aosStartMinute..aosEndMinute
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
} else {
|
} else {
|
||||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||||
}
|
}
|
||||||
return if (invertAosTimeWindow) !inRange else inRange
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
}
|
}
|
||||||
@@ -243,13 +236,16 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation < 0.0)
|
} while (elevation < 0.0)
|
||||||
|
|
||||||
// refine AOS to ~500ms precision
|
// refine AOS to ~500ms precision via binary search.
|
||||||
calendarTimeMillis -= 60L * 1000L
|
// Elevation is monotonic across the horizon crossing, so binary search
|
||||||
do {
|
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
|
||||||
calendarTimeMillis += 500L
|
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
|
||||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
|
||||||
if (elevation > maxElevation) maxElevation = elevation
|
while (aosHi - aosLo > 500L) {
|
||||||
} while (elevation < 0.0)
|
val mid = (aosLo + aosHi) / 2
|
||||||
|
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
|
||||||
|
}
|
||||||
|
calendarTimeMillis = aosHi
|
||||||
|
|
||||||
// Get full position for AOS data (azimuth, altitude)
|
// Get full position for AOS data (azimuth, altitude)
|
||||||
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
@@ -263,13 +259,14 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation > 0.0)
|
} while (elevation > 0.0)
|
||||||
|
|
||||||
// refine LOS to ~500ms precision
|
// refine LOS to ~500ms precision via binary search (same monotonic argument)
|
||||||
calendarTimeMillis -= 30L * 1000L
|
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
|
||||||
do {
|
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
|
||||||
calendarTimeMillis += 500L
|
while (losHi - losLo > 500L) {
|
||||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
val mid = (losLo + losHi) / 2
|
||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
|
||||||
} while (elevation > 0.0)
|
}
|
||||||
|
calendarTimeMillis = losHi
|
||||||
|
|
||||||
// Get full position for LOS data (azimuth, altitude)
|
// Get full position for LOS data (azimuth, altitude)
|
||||||
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
+54
-27
@@ -29,6 +29,7 @@ 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.source.Sources
|
||||||
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.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -68,7 +69,8 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
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"
|
||||||
@@ -86,8 +88,6 @@ class SettingsRepo(
|
|||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
|
||||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
@@ -96,9 +96,9 @@ class SettingsRepo(
|
|||||||
|
|
||||||
//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)
|
||||||
@@ -106,10 +106,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> {
|
||||||
@@ -118,10 +118,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
|
||||||
|
|
||||||
@@ -136,6 +136,7 @@ class SettingsRepo(
|
|||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
|
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,13 +147,16 @@ class SettingsRepo(
|
|||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
|
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||||
|
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||||
return PassesSettings(
|
return PassesSettings(
|
||||||
showDeepSpace,
|
showDeepSpace,
|
||||||
hoursAhead,
|
hoursAhead,
|
||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow
|
invertAosTimeWindow,
|
||||||
|
selectedModes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -210,8 +214,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
||||||
val newLat = latitude.round(4)
|
val newLat = latitude.round(5)
|
||||||
val newLon = longitude.round(4)
|
val newLon = longitude.round(5)
|
||||||
val newAlt = altitude.round(1)
|
val newAlt = altitude.round(1)
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
||||||
@@ -232,6 +236,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)
|
||||||
@@ -334,8 +357,6 @@ class SettingsRepo(
|
|||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
|
||||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -352,9 +373,7 @@ class SettingsRepo(
|
|||||||
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())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
|
||||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -372,12 +391,20 @@ class SettingsRepo(
|
|||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||||
)
|
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||||
|
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||||
|
return DataSourcesSettings(
|
||||||
|
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||||
|
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||||
|
tleUrl = tleUrl,
|
||||||
|
transceiversUrl = txUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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(
|
||||||
@@ -45,49 +46,13 @@ class RemoteSource(
|
|||||||
override suspend fun getNetworkStream(url: String): InputStream? = 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
|
||||||
response.close()
|
ByteArrayInputStream(response.body.bytes())
|
||||||
return@withContext null
|
|
||||||
}
|
}
|
||||||
// Return the body stream directly as the caller is responsible for closing it
|
|
||||||
// That returns the connection to OkHttp's pool
|
|
||||||
response.body.byteStream().buffered()
|
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+13
-3
@@ -62,6 +62,7 @@ class DatabaseRepoTest {
|
|||||||
|
|
||||||
assertEquals(1, localSource.insertedEntries.size)
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +102,8 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
|
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
@@ -163,10 +166,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||||
)
|
)
|
||||||
|
|
||||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
@@ -187,9 +190,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
override fun setSelectedSatModes(modes: List<String>) = Unit
|
override fun setSelectedTypes(types: List<String>) = Unit
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
@@ -199,6 +204,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean = true
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
|
satelliteTypeIdsByType[type] = ids
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) {
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
databaseState.value = state
|
databaseState.value = state
|
||||||
|
|||||||
-173
@@ -1,173 +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, "", "", "", false, "", "", "", false, "", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
|
||||||
OtherSettings(false, false, false, false, false, false, false, false)
|
|
||||||
)
|
|
||||||
|
|
||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
|
||||||
DataSourcesSettings(false, false, "", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
|
||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
|
||||||
|
|
||||||
override fun setSelectedSatModes(modes: List<String>) {
|
|
||||||
selectedSatModes.value = modes
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
|
||||||
|
|
||||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
|
||||||
|
|
||||||
override fun setStationPosition(): Boolean = true
|
|
||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean = true
|
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) {
|
|
||||||
databaseState.value = state
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
|
||||||
|
|
||||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bayesian Morse timing decoder.
|
||||||
|
* Replaces hard thresholds with probability-based decision making.
|
||||||
|
*
|
||||||
|
* Inspired by VE3NEA's CW Skimmer approach:
|
||||||
|
* "Instead of making a hard decision at every input sample whether the signal
|
||||||
|
* is present or not, compute the probability that the signal is present."
|
||||||
|
*
|
||||||
|
* Uses Gaussian probability density centered on expected durations:
|
||||||
|
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
|
||||||
|
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
|
||||||
|
*/
|
||||||
|
internal class CwBayesianDecoder {
|
||||||
|
|
||||||
|
// Morse timing parameters
|
||||||
|
private var dotDurationMs = 60f // initial 20 WPM
|
||||||
|
private var speedWpm = 20f
|
||||||
|
|
||||||
|
// Current symbol being accumulated
|
||||||
|
private var currentSymbol = StringBuilder()
|
||||||
|
private var textBuffer = StringBuilder()
|
||||||
|
|
||||||
|
// Recent dit lengths for speed estimation
|
||||||
|
private val recentDits = mutableListOf<Float>()
|
||||||
|
|
||||||
|
// Output
|
||||||
|
private var _decodedText = ""
|
||||||
|
val decodedText: String get() = _decodedText
|
||||||
|
|
||||||
|
/** Gaussian probability. */
|
||||||
|
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
||||||
|
if (varianceMs <= 0f) return 0f
|
||||||
|
val diff = durationMs - expectedMs
|
||||||
|
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process a tone duration. Returns the symbol type with highest probability. */
|
||||||
|
fun processTone(durationMs: Float): ToneResult {
|
||||||
|
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
|
||||||
|
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||||
|
|
||||||
|
return if (ditProb > dashProb && ditProb > 0.05f) {
|
||||||
|
currentSymbol.append('0')
|
||||||
|
recentDits.add(durationMs)
|
||||||
|
updateSpeed()
|
||||||
|
ToneResult('0', ditProb)
|
||||||
|
} else if (dashProb > 0.05f) {
|
||||||
|
currentSymbol.append('1')
|
||||||
|
ToneResult('1', dashProb)
|
||||||
|
} else {
|
||||||
|
ToneResult(null, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process a gap duration. Returns decoded character or null. */
|
||||||
|
fun processGap(durationMs: Float): Char? {
|
||||||
|
if (currentSymbol.isEmpty()) {
|
||||||
|
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||||
|
if (wordProb > 0.2f) {
|
||||||
|
textBuffer.append(' ')
|
||||||
|
_decodedText = textBuffer.toString()
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||||
|
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||||
|
|
||||||
|
if (wordProb > interCharProb && wordProb > 0.2f) {
|
||||||
|
val char = flushSymbol()
|
||||||
|
textBuffer.append(' ')
|
||||||
|
_decodedText = textBuffer.toString()
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
if (interCharProb > 0.15f) {
|
||||||
|
val char = flushSymbol()
|
||||||
|
_decodedText = textBuffer.toString()
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushSymbol(): Char? {
|
||||||
|
if (currentSymbol.isEmpty()) return null
|
||||||
|
val morse = currentSymbol.toString()
|
||||||
|
currentSymbol.clear()
|
||||||
|
val char = morseToChar(morse)
|
||||||
|
if (char != null) textBuffer.append(char)
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSpeed() {
|
||||||
|
if (recentDits.size < 3) return
|
||||||
|
val sorted = recentDits.sorted()
|
||||||
|
val median = sorted[sorted.size / 2]
|
||||||
|
if (median > 0f) {
|
||||||
|
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
||||||
|
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
||||||
|
if (wpm in 5f..55f) speedWpm = wpm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSpeed(): Float = speedWpm
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
dotDurationMs = 60f
|
||||||
|
speedWpm = 20f
|
||||||
|
recentDits.clear()
|
||||||
|
currentSymbol.clear()
|
||||||
|
textBuffer.clear()
|
||||||
|
_decodedText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val MORSE_TABLE = mapOf(
|
||||||
|
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
||||||
|
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
||||||
|
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
||||||
|
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
||||||
|
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
||||||
|
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
||||||
|
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
||||||
|
"11100" to '8', "11110" to '9', "11111" to '0',
|
||||||
|
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
||||||
|
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
||||||
|
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
||||||
|
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
||||||
|
"0001001" to '$', "011010" to '@'
|
||||||
|
)
|
||||||
|
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ToneResult(val symbol: Char?, val probability: Float)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-channel CW signal tracker.
|
||||||
|
* Monitors the spectrogram for active frequency bins and extracts
|
||||||
|
* energy envelopes for each detected signal.
|
||||||
|
*
|
||||||
|
* Inspired by CW Skimmer's multi-channel approach:
|
||||||
|
* tracks all active signals in the passband simultaneously,
|
||||||
|
* selects the best one for decoded output.
|
||||||
|
*/
|
||||||
|
internal class CwChannelTracker(
|
||||||
|
private val spectrogram: CwSpectrogram,
|
||||||
|
private val maxChannels: Int = 3
|
||||||
|
) {
|
||||||
|
data class Channel(
|
||||||
|
val bin: Int,
|
||||||
|
val frequency: Float,
|
||||||
|
var active: Boolean = false,
|
||||||
|
var energy: Float = 0f,
|
||||||
|
val history: MutableList<Float> = mutableListOf(),
|
||||||
|
var confidence: Float = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
private val channels = Array(maxChannels) { Channel(0, 0f) }
|
||||||
|
|
||||||
|
/** Scan the current spectrogram column and update channel tracking. */
|
||||||
|
fun update(): List<Channel> {
|
||||||
|
val col = spectrogram.getCurrentColumn()
|
||||||
|
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
||||||
|
|
||||||
|
// Update existing channels
|
||||||
|
for (ch in channels) {
|
||||||
|
if (ch.active) {
|
||||||
|
if (peaks.contains(ch.bin)) {
|
||||||
|
ch.energy = col[ch.bin]
|
||||||
|
ch.history.add(ch.energy)
|
||||||
|
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||||
|
ch.confidence = computeConfidence(ch.history)
|
||||||
|
} else {
|
||||||
|
// Signal lost — decay confidence
|
||||||
|
ch.history.add(0f)
|
||||||
|
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||||
|
ch.confidence *= 0.9f
|
||||||
|
if (ch.confidence < 0.1f) ch.active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign new peaks to inactive channels
|
||||||
|
var peakIdx = 0
|
||||||
|
for (ch in channels) {
|
||||||
|
if (!ch.active && peakIdx < peaks.size) {
|
||||||
|
val bin = peaks[peakIdx]
|
||||||
|
val freq = spectrogram.binToFreq(bin)
|
||||||
|
// Re-initialize channel
|
||||||
|
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
|
||||||
|
peakIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels.filter { it.active }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find peak bins in the spectrum. */
|
||||||
|
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
||||||
|
val peaks = mutableListOf<Int>()
|
||||||
|
for (i in 1 until spectrum.size - 1) {
|
||||||
|
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
||||||
|
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
||||||
|
peaks.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peaks.sortedByDescending { spectrum[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute confidence from energy history. Lower variance = higher confidence. */
|
||||||
|
private fun computeConfidence(history: List<Float>): Float {
|
||||||
|
if (history.size < 10) return 0.3f
|
||||||
|
val recent = history.takeLast(10)
|
||||||
|
val mean = recent.average().toFloat()
|
||||||
|
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
||||||
|
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the channel with highest confidence. */
|
||||||
|
fun getBestChannel(): Channel? {
|
||||||
|
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
for (i in channels.indices) {
|
||||||
|
channels[i] = Channel(0, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
|
||||||
|
*
|
||||||
|
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
|
||||||
|
* 1. FFT spectrogram creates a frequency×time matrix
|
||||||
|
* 2. Multi-channel peak detector finds all active signals
|
||||||
|
* 3. Per-channel energy envelope extraction
|
||||||
|
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
|
||||||
|
* 5. Best channel selected for output
|
||||||
|
*
|
||||||
|
* Timing analysis is performed per spectrogram column (hop).
|
||||||
|
* Each column represents hopSize/sampleRate seconds of audio.
|
||||||
|
*/
|
||||||
|
class CwDecoder(
|
||||||
|
val sampleRate: Int = 8000,
|
||||||
|
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val FFT_SIZE = 256
|
||||||
|
private const val HOP_SIZE = 64
|
||||||
|
}
|
||||||
|
|
||||||
|
private val spectrogram = CwSpectrogram(
|
||||||
|
fftSize = FFT_SIZE,
|
||||||
|
hopSize = HOP_SIZE,
|
||||||
|
sampleRate = sampleRate,
|
||||||
|
minBin = 6,
|
||||||
|
maxBin = 38,
|
||||||
|
historyCols = 40
|
||||||
|
)
|
||||||
|
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
|
||||||
|
private val bayesianDecoder = CwBayesianDecoder()
|
||||||
|
|
||||||
|
// Timing state per channel
|
||||||
|
private data class ChannelTiming(
|
||||||
|
var isSignal: Boolean = false,
|
||||||
|
var toneTicks: Int = 0,
|
||||||
|
var gapTicks: Int = 0
|
||||||
|
)
|
||||||
|
private val timingStates = Array(3) { ChannelTiming() }
|
||||||
|
|
||||||
|
// Time per spectrogram column in milliseconds
|
||||||
|
private val tickMs = 1000f * HOP_SIZE / sampleRate
|
||||||
|
|
||||||
|
// Output flows
|
||||||
|
private val _decodedTextFlow = MutableStateFlow("")
|
||||||
|
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||||
|
|
||||||
|
private val _signalStrength = MutableStateFlow(0f)
|
||||||
|
val signalStrength: StateFlow<Float> = _signalStrength
|
||||||
|
|
||||||
|
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||||
|
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||||
|
|
||||||
|
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||||
|
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||||
|
|
||||||
|
private var frameCount = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (cwToneFreq > 0f) {
|
||||||
|
_estimatedPitch.value = cwToneFreq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processBuffer(buffer: FloatArray) {
|
||||||
|
// 1. Feed samples to spectrogram
|
||||||
|
spectrogram.addSamples(buffer)
|
||||||
|
|
||||||
|
// 2. Get number of new columns generated
|
||||||
|
val newCols = spectrogram.getNewColumns()
|
||||||
|
if (newCols == 0) return
|
||||||
|
|
||||||
|
// 3. Update channel tracker (uses latest column for peak detection)
|
||||||
|
val activeChannels = channelTracker.update()
|
||||||
|
|
||||||
|
// 4. Process each new column for timing analysis
|
||||||
|
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
|
||||||
|
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
|
||||||
|
for (colOffset in 0 until newCols) {
|
||||||
|
val col = spectrogram.getColumn(baseIdx + colOffset)
|
||||||
|
|
||||||
|
for ((idx, channel) in activeChannels.withIndex()) {
|
||||||
|
if (idx >= timingStates.size) break
|
||||||
|
val state = timingStates[idx]
|
||||||
|
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
||||||
|
|
||||||
|
// Adaptive threshold
|
||||||
|
val threshold = 0.3f + (energy - 0.3f) * 0.3f
|
||||||
|
|
||||||
|
if (energy > threshold) {
|
||||||
|
if (!state.isSignal) {
|
||||||
|
if (state.gapTicks > 0) {
|
||||||
|
val gapMs = state.gapTicks * tickMs
|
||||||
|
bayesianDecoder.processGap(gapMs)
|
||||||
|
}
|
||||||
|
state.gapTicks = 0
|
||||||
|
state.isSignal = true
|
||||||
|
}
|
||||||
|
state.toneTicks++
|
||||||
|
} else {
|
||||||
|
if (state.isSignal) {
|
||||||
|
if (state.toneTicks > 0) {
|
||||||
|
val toneMs = state.toneTicks * tickMs
|
||||||
|
bayesianDecoder.processTone(toneMs)
|
||||||
|
}
|
||||||
|
state.toneTicks = 0
|
||||||
|
state.isSignal = false
|
||||||
|
}
|
||||||
|
state.gapTicks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update outputs
|
||||||
|
frameCount++
|
||||||
|
if (frameCount % 5 == 0) {
|
||||||
|
val bestChannel = channelTracker.getBestChannel()
|
||||||
|
if (bestChannel != null) {
|
||||||
|
_estimatedPitch.value = bestChannel.frequency
|
||||||
|
_signalStrength.value = bestChannel.confidence
|
||||||
|
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
||||||
|
}
|
||||||
|
_decodedTextFlow.value = bayesianDecoder.decodedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetDecoder() {
|
||||||
|
spectrogram.reset()
|
||||||
|
channelTracker.reset()
|
||||||
|
bayesianDecoder.reset()
|
||||||
|
for (state in timingStates) {
|
||||||
|
state.isSignal = false
|
||||||
|
state.toneTicks = 0
|
||||||
|
state.gapTicks = 0
|
||||||
|
}
|
||||||
|
frameCount = 0
|
||||||
|
_decodedTextFlow.value = ""
|
||||||
|
_signalStrength.value = 0f
|
||||||
|
_estimatedPitch.value = null
|
||||||
|
_estimatedSpeed.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DSP utilities for CW (Morse code) decoding.
|
||||||
|
* Pure Kotlin, no NDK required.
|
||||||
|
*/
|
||||||
|
internal object CwDsp {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
|
||||||
|
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
|
||||||
|
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
|
||||||
|
* @param taps filter length (must be odd)
|
||||||
|
*/
|
||||||
|
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
|
||||||
|
val n = if (taps % 2 == 0) taps + 1 else taps
|
||||||
|
val half = n / 2
|
||||||
|
val coeffs = FloatArray(n)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val idx = i - half
|
||||||
|
if (idx == 0) {
|
||||||
|
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
|
||||||
|
} else {
|
||||||
|
val x = PI * idx
|
||||||
|
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
|
||||||
|
}
|
||||||
|
// Hamming window
|
||||||
|
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
|
||||||
|
}
|
||||||
|
// Normalize
|
||||||
|
val sum = coeffs.sum()
|
||||||
|
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
|
||||||
|
return coeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply FIR filter to a buffer. */
|
||||||
|
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
|
||||||
|
val out = FloatArray(buffer.size)
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
var sum = 0f
|
||||||
|
for (j in coeffs.indices) {
|
||||||
|
val idx = i - j
|
||||||
|
if (idx >= 0) sum += buffer[idx] * coeffs[j]
|
||||||
|
}
|
||||||
|
out[i] = sum
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple envelope detector: abs + low-pass smoothing. */
|
||||||
|
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
|
||||||
|
val env = FloatArray(signal.size)
|
||||||
|
var s = 0f
|
||||||
|
for (i in signal.indices) {
|
||||||
|
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
|
||||||
|
env[i] = s
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimate noise floor from envelope for adaptive thresholding. */
|
||||||
|
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
|
||||||
|
val sorted = env.sortedArray()
|
||||||
|
val median = sorted[sorted.size / 2]
|
||||||
|
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple Goertzel to detect a specific tone frequency. */
|
||||||
|
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
|
||||||
|
val omega = 2.0 * PI * targetFreq / sampleRate
|
||||||
|
val coeff = 2.0 * cos(omega)
|
||||||
|
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
|
||||||
|
for (sample in buffer) {
|
||||||
|
s0 = sample.toDouble() + coeff * s1 - s2
|
||||||
|
s2 = s1; s1 = s0
|
||||||
|
}
|
||||||
|
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
|
||||||
|
return sqrt(kotlin.math.abs(power)).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radix-2 FFT for real-valued input.
|
||||||
|
* Produces magnitude spectrum for the first N/2+1 bins.
|
||||||
|
* Used by CwSpectrogram for time-frequency analysis.
|
||||||
|
*/
|
||||||
|
internal class CwFFT(private val n: Int) {
|
||||||
|
init {
|
||||||
|
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cosTable = FloatArray(n / 2)
|
||||||
|
private val sinTable = FloatArray(n / 2)
|
||||||
|
|
||||||
|
init {
|
||||||
|
for (i in 0 until n / 2) {
|
||||||
|
val angle = -2.0 * kotlin.math.PI * i / n
|
||||||
|
cosTable[i] = cos(angle).toFloat()
|
||||||
|
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
||||||
|
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
||||||
|
require(input.size == n) { "Input size must be $n, got ${input.size}" }
|
||||||
|
|
||||||
|
val real = input.copyOf()
|
||||||
|
val imag = FloatArray(n)
|
||||||
|
|
||||||
|
// Bit-reversal permutation
|
||||||
|
var j = 0
|
||||||
|
for (i in 1 until n) {
|
||||||
|
var bit = n shr 1
|
||||||
|
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||||
|
j = j xor bit
|
||||||
|
if (i < j) {
|
||||||
|
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radix-2 Cooley-Tukey FFT
|
||||||
|
var len = 2
|
||||||
|
while (len <= n) {
|
||||||
|
val half = len / 2
|
||||||
|
val step = n / len
|
||||||
|
for (i in 0 until n step len) {
|
||||||
|
for (k in 0 until half) {
|
||||||
|
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
||||||
|
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
||||||
|
real[i + k + half] = real[i + k] - tReal
|
||||||
|
imag[i + k + half] = imag[i + k] - tImag
|
||||||
|
real[i + k] += tReal
|
||||||
|
imag[i + k] += tImag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
len = len shl 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magnitude spectrum (first N/2+1 bins)
|
||||||
|
val mag = FloatArray(n / 2 + 1)
|
||||||
|
for (i in 0..n / 2) {
|
||||||
|
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
||||||
|
}
|
||||||
|
return mag
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-order IIR filters.
|
||||||
|
* Ported from ggmorse/src/filter.h
|
||||||
|
*/
|
||||||
|
internal class CwFilter {
|
||||||
|
private var z1 = 0f
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PI_F = 3.141592653589793f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||||
|
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||||
|
val dt = 1.0f / sampleRate
|
||||||
|
val alpha = dt / (rc + dt)
|
||||||
|
z1 = alpha * (z1 + sample - z1)
|
||||||
|
return sample - z1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||||
|
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||||
|
val dt = 1.0f / sampleRate
|
||||||
|
val alpha = dt / (rc + dt)
|
||||||
|
z1 += alpha * (sample - z1)
|
||||||
|
return z1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() { z1 = 0f }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Running Goertzel filter for CW tone detection.
|
||||||
|
* Tracks a specific frequency over time with a sliding window.
|
||||||
|
* Ported from ggmorse/src/goertzel.h
|
||||||
|
*/
|
||||||
|
internal class CwGoertzel {
|
||||||
|
private var s1 = 0.0
|
||||||
|
private var s2 = 0.0
|
||||||
|
private var coeff = 0.0
|
||||||
|
|
||||||
|
fun init(sampleRate: Float, targetFreq: Float) {
|
||||||
|
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
||||||
|
coeff = 2.0 * cos(omega)
|
||||||
|
s1 = 0.0
|
||||||
|
s2 = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun process(sample: Float) {
|
||||||
|
val s0 = sample.toDouble() + coeff * s1 - s2
|
||||||
|
s2 = s1
|
||||||
|
s1 = s0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPower(): Float {
|
||||||
|
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
s1 = 0.0
|
||||||
|
s2 = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple linear resampler.
|
||||||
|
* Downsamples from input sample rate to output sample rate.
|
||||||
|
* Ported from ggmorse/src/resampler.h
|
||||||
|
*/
|
||||||
|
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
||||||
|
private val ratio = inputRate / outputRate
|
||||||
|
private var lastSample = 0f
|
||||||
|
|
||||||
|
fun process(input: FloatArray): FloatArray {
|
||||||
|
if (ratio <= 0f || input.isEmpty()) return input
|
||||||
|
val outputLen = (input.size / ratio).toInt() + 1
|
||||||
|
val output = FloatArray(outputLen)
|
||||||
|
var idx = 0f
|
||||||
|
for (i in output.indices) {
|
||||||
|
val intIdx = idx.toInt()
|
||||||
|
val frac = idx - intIdx
|
||||||
|
if (intIdx + 1 < input.size) {
|
||||||
|
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
||||||
|
} else if (intIdx < input.size) {
|
||||||
|
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
||||||
|
} else {
|
||||||
|
output[i] = lastSample
|
||||||
|
}
|
||||||
|
idx += ratio
|
||||||
|
}
|
||||||
|
lastSample = input.lastOrNull() ?: lastSample
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
lastSample = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight pitch detector using DFT at specific frequency bins.
|
||||||
|
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
||||||
|
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
||||||
|
*/
|
||||||
|
internal class CwPitchDetector(
|
||||||
|
private val sampleRate: Float,
|
||||||
|
private val minFreq: Float = 200f,
|
||||||
|
private val maxFreq: Float = 1200f,
|
||||||
|
private val stepHz: Float = 10f
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Find the dominant pitch frequency in the buffer.
|
||||||
|
* Returns null if no significant pitch found.
|
||||||
|
*/
|
||||||
|
fun findPitch(buffer: FloatArray): Float? {
|
||||||
|
if (buffer.isEmpty()) return null
|
||||||
|
var bestFreq = 0f
|
||||||
|
var bestPower = 0f
|
||||||
|
var freq = minFreq
|
||||||
|
while (freq <= maxFreq) {
|
||||||
|
var real = 0.0
|
||||||
|
var imag = 0.0
|
||||||
|
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
real += buffer[i] * cos(omega * i)
|
||||||
|
imag += buffer[i] * -sin(omega * i)
|
||||||
|
}
|
||||||
|
val power = (real * real + imag * imag).toFloat()
|
||||||
|
if (power > bestPower) {
|
||||||
|
bestPower = power
|
||||||
|
bestFreq = freq
|
||||||
|
}
|
||||||
|
freq += stepHz
|
||||||
|
}
|
||||||
|
return if (bestPower > 0.001f) bestFreq else null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding-window spectrogram for CW decoding.
|
||||||
|
* Maintains a time-frequency matrix updated with each audio frame.
|
||||||
|
*
|
||||||
|
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
|
||||||
|
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
|
||||||
|
* History: 40 columns (320 ms window)
|
||||||
|
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
|
||||||
|
*/
|
||||||
|
internal class CwSpectrogram(
|
||||||
|
private val fftSize: Int = 256,
|
||||||
|
private val hopSize: Int = 64,
|
||||||
|
private val sampleRate: Int = 4000,
|
||||||
|
private val minBin: Int = 6,
|
||||||
|
private val maxBin: Int = 38,
|
||||||
|
val historyCols: Int = 40
|
||||||
|
) {
|
||||||
|
private val fft = CwFFT(fftSize)
|
||||||
|
val numBins: Int get() = maxBin - minBin + 1
|
||||||
|
|
||||||
|
// Hanning window
|
||||||
|
private val hanning = FloatArray(fftSize) {
|
||||||
|
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spectrogram data: [timeCol][freqBin]
|
||||||
|
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
||||||
|
private var currentCol = 0
|
||||||
|
private var samplesBuffered = 0
|
||||||
|
private val buffer = FloatArray(fftSize)
|
||||||
|
|
||||||
|
// Per-bin running energy for normalization
|
||||||
|
private val binEnergy = FloatArray(numBins) { 1f }
|
||||||
|
private val alpha = 0.95f
|
||||||
|
|
||||||
|
// Counter for new columns generated since last check
|
||||||
|
private var newColumnCount = 0
|
||||||
|
|
||||||
|
/** Add audio samples, compute FFTs for each complete hop. */
|
||||||
|
fun addSamples(samples: FloatArray) {
|
||||||
|
var offset = 0
|
||||||
|
while (offset < samples.size) {
|
||||||
|
val needed = fftSize - samplesBuffered
|
||||||
|
val copyLen = minOf(needed, samples.size - offset)
|
||||||
|
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
||||||
|
samplesBuffered += copyLen
|
||||||
|
offset += copyLen
|
||||||
|
|
||||||
|
if (samplesBuffered >= fftSize) {
|
||||||
|
processFrame()
|
||||||
|
newColumnCount++
|
||||||
|
// Shift buffer: keep last (fftSize - hopSize) samples
|
||||||
|
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
||||||
|
samplesBuffered = fftSize - hopSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get number of new columns generated since the last call to this method. */
|
||||||
|
fun getNewColumns(): Int {
|
||||||
|
val count = newColumnCount
|
||||||
|
newColumnCount = 0
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processFrame() {
|
||||||
|
// Apply Hanning window
|
||||||
|
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
||||||
|
|
||||||
|
// Compute FFT magnitude spectrum
|
||||||
|
val mag = fft.magnitudeSpectrum(windowed)
|
||||||
|
|
||||||
|
// Update spectrogram column
|
||||||
|
val col = spectrogram[currentCol]
|
||||||
|
for (b in 0 until numBins) {
|
||||||
|
val binIdx = minBin + b
|
||||||
|
val rawMag = mag[binIdx]
|
||||||
|
// Running energy normalization
|
||||||
|
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
||||||
|
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCol = (currentCol + 1) % historyCols
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current spectrogram as a 2D array in chronological order. */
|
||||||
|
fun getSpectrogram(): Array<FloatArray> {
|
||||||
|
val result = Array(historyCols) { i ->
|
||||||
|
val srcIdx = (currentCol + i) % historyCols
|
||||||
|
spectrogram[srcIdx].copyOf()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the most recent column (current energy across all frequencies). */
|
||||||
|
fun getCurrentColumn(): FloatArray {
|
||||||
|
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
||||||
|
return spectrogram[prevCol].copyOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
|
||||||
|
fun getColumn(index: Int): FloatArray {
|
||||||
|
val clamped = index.coerceIn(0, historyCols - 1)
|
||||||
|
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
|
||||||
|
return spectrogram[srcIdx].copyOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
|
||||||
|
fun findPeakBin(): Int {
|
||||||
|
val col = getCurrentColumn()
|
||||||
|
var maxBin = -1
|
||||||
|
var maxVal = 0f
|
||||||
|
for (i in col.indices) {
|
||||||
|
if (col[i] > maxVal) {
|
||||||
|
maxVal = col[i]
|
||||||
|
maxBin = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (maxVal > 0.3f) maxBin else -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get energy at a specific bin over the last N columns in chronological order. */
|
||||||
|
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
||||||
|
val clamped = minOf(numCols, historyCols)
|
||||||
|
val result = FloatArray(clamped)
|
||||||
|
for (i in 0 until clamped) {
|
||||||
|
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
|
||||||
|
result[i] = spectrogram[colIdx][bin]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the bin index for a frequency in Hz. */
|
||||||
|
fun freqToBin(freqHz: Float): Int {
|
||||||
|
val bin = (freqHz * fftSize / sampleRate).toInt()
|
||||||
|
return (bin - minBin).coerceIn(0, numBins - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the center frequency for a bin. */
|
||||||
|
fun binToFreq(bin: Int): Float {
|
||||||
|
return (minBin + bin).toFloat() * sampleRate / fftSize
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
for (col in spectrogram) col.fill(0f)
|
||||||
|
currentCol = 0
|
||||||
|
samplesBuffered = 0
|
||||||
|
buffer.fill(0f)
|
||||||
|
binEnergy.fill(1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, 6 days of state */
|
|
||||||
data class SatStatus(
|
|
||||||
val name: String, // "AO-123_[FM]"
|
|
||||||
val days: List<SatDay> // 6 天(新→旧)
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Overall page parse result */
|
|
||||||
data class SatStatusPage(
|
|
||||||
val fetchedAtUtcMs: Long,
|
|
||||||
val statuses: List<SatStatus>,
|
|
||||||
val reports: Map<String, SatReport> // id → 报告
|
|
||||||
)
|
|
||||||
@@ -29,7 +29,8 @@ data class PassesSettings(
|
|||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val aosStartMinute: Int = 0,
|
val aosStartMinute: Int = 0,
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
val invertAosTimeWindow: Boolean = false
|
val invertAosTimeWindow: Boolean = false,
|
||||||
|
val selectedModes: List<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -61,9 +62,7 @@ data class OtherSettings(
|
|||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0,
|
val highElevation: Double = 45.0
|
||||||
val radarCompassOffset: Float = 0f,
|
|
||||||
val radarCompassOffsetElev: Float = 0f
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
|
|||||||
-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?
|
|
||||||
}
|
|
||||||
+23
-3
@@ -16,12 +16,13 @@
|
|||||||
* 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.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface IMainContainer {
|
interface IMainContainer {
|
||||||
val appScope: CoroutineScope
|
val appScope: CoroutineScope
|
||||||
@@ -29,8 +30,9 @@ 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
|
||||||
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
|
fun setMutualPassData(data: MutualPassData)
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
@@ -40,9 +42,27 @@ 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>>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class MutualPassData(
|
||||||
|
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
||||||
|
val trackSamples: List<TrackSampleData> = emptyList(),
|
||||||
|
val startTime: Long = 0L,
|
||||||
|
val endTime: Long = 0L,
|
||||||
|
val maxElev: Double = 10.0,
|
||||||
|
val labelA: String = "你",
|
||||||
|
val labelB: String = "友台"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Minimal track sample for cross-module sharing (angles in degrees). */
|
||||||
|
data class TrackSampleData(
|
||||||
|
val time: Long = 0L,
|
||||||
|
val azimuthA: Double,
|
||||||
|
val elevationA: Double,
|
||||||
|
val azimuthB: Double,
|
||||||
|
val elevationB: Double
|
||||||
|
)
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
fun getMainContainer(): IMainContainer
|
fun getMainContainer(): IMainContainer
|
||||||
}
|
}
|
||||||
+1
-1
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
|
|||||||
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
||||||
|
|
||||||
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
||||||
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
|
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
|
||||||
|
|
||||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
|
|||||||
+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
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,4 @@ import java.io.InputStream
|
|||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): InputStream?
|
||||||
suspend fun getAmSatCatalog(): String?
|
|
||||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
|
||||||
}
|
}
|
||||||
@@ -18,26 +18,41 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
|
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
|
||||||
|
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
|
||||||
|
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
|
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||||
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
|
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||||
|
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
|
||||||
|
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
|
||||||
|
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
|
||||||
|
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
|
||||||
|
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
|
||||||
|
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
|
||||||
|
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
|
||||||
|
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
|
||||||
|
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
|
||||||
|
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||||
|
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||||
|
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||||
|
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||||
|
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||||
|
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||||
|
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||||
|
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
|
||||||
|
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
|
||||||
|
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
|
||||||
|
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||||
|
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
|
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
|
||||||
"ARISS" to "https://live.ariss.org/iss.txt",
|
|
||||||
"Other" to "" // key for sats filter
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = mapOf(
|
val transceiversDataUrls = mapOf(
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
)
|
)
|
||||||
val satelliteModes = listOf(
|
|
||||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
|
||||||
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
|
|
||||||
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
|
|
||||||
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
|
|
||||||
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
|
|
||||||
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
|
|
||||||
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
val line1 = tle[1]
|
val 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,
|
||||||
|
|||||||
+4
-28
@@ -11,17 +11,18 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
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.predict.OrbitalPos
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
*
|
*
|
||||||
* For a linear (passband) transponder, uplink and downlink frequencies are
|
* For a linear (passband) transponder, uplink and downlink frequencies are
|
||||||
* related by a fixed passband offset. When the satellite moves, both are
|
* related by a fixed passband offset. When the satellite moves, both are
|
||||||
* Doppler-shifted. Given one, this computes the other:
|
* Doppler-shifted. Given one, we compute the other:
|
||||||
*
|
*
|
||||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||||
|
*
|
||||||
|
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
|
||||||
*/
|
*/
|
||||||
object DopplerFrequencyCalculator {
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
@@ -43,9 +44,6 @@ object DopplerFrequencyCalculator {
|
|||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
* with an offset applied to the downlink (in Hz).
|
* with an offset applied to the downlink (in Hz).
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* 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(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -54,7 +52,7 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,26 +95,4 @@ object DopplerFrequencyCalculator {
|
|||||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
&& upLow != upHigh && downLow != downHigh
|
&& upLow != upHigh && downLow != downHigh
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True for the radio entry that should drive the standalone Calculator page.
|
|
||||||
*
|
|
||||||
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
|
||||||
* can also have low/high frequencies. The calculator is meant for named linear
|
|
||||||
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
|
||||||
*/
|
|
||||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
|
||||||
if (!isLinearTransponder(transponder)) return false
|
|
||||||
|
|
||||||
val info = transponder.info.lowercase(Locale.ENGLISH)
|
|
||||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
|
||||||
.joinToString(separator = " ")
|
|
||||||
.lowercase(Locale.ENGLISH)
|
|
||||||
val hasLinearName = info.contains("linear")
|
|
||||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
|
||||||
info.contains("xponder") || info.contains("xpdr")
|
|
||||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
|
||||||
|
|
||||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+106
-15
@@ -19,31 +19,78 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
|
||||||
|
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
|
||||||
|
* The returned position is the center of the finest cell encoded by the locator:
|
||||||
|
* - 6 char: 5' lon x 2.5' lat cell center
|
||||||
|
* - 8 char: 30" lon x 15" lat cell center
|
||||||
|
* - 10 char: 1.25" lon x 0.625" lat cell center
|
||||||
|
*/
|
||||||
fun qthToPosition(locator: String): GeoPos? {
|
fun qthToPosition(locator: String): GeoPos? {
|
||||||
val trimmedQth = locator.take(6)
|
val trimmedQth = locator.trim().uppercase()
|
||||||
if (!isValidLocator(trimmedQth)) return null
|
if (!isValidLocator(trimmedQth)) return null
|
||||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||||
val latSecond = trimmedQth[3].toString().toInt()
|
val latSecond = trimmedQth[3].toString().toInt()
|
||||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
|
||||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
|
||||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
var longitude = lonFirst + lonSecond + lonThird - 180
|
||||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
var latitude = latFirst + latSecond + latThird - 90
|
||||||
return GeoPos(latitude, longitude)
|
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
|
||||||
|
if (trimmedQth.length >= 8) {
|
||||||
|
longitude += trimmedQth[6].toString().toInt() / 120.0
|
||||||
|
latitude += trimmedQth[7].toString().toInt() / 240.0
|
||||||
|
}
|
||||||
|
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
|
||||||
|
if (trimmedQth.length >= 10) {
|
||||||
|
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
|
||||||
|
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
|
||||||
|
}
|
||||||
|
// Offset to the center of the finest encoded cell
|
||||||
|
when (trimmedQth.length) {
|
||||||
|
8 -> {
|
||||||
|
longitude += 1.0 / 240.0
|
||||||
|
latitude += 1.0 / 480.0
|
||||||
|
}
|
||||||
|
10 -> {
|
||||||
|
longitude += 1.0 / 5760.0
|
||||||
|
latitude += 1.0 / 11520.0
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
longitude += 1.0 / 24.0
|
||||||
|
latitude += 1.0 / 48.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GeoPos(latitude.round(6), longitude.round(6))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
/**
|
||||||
|
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
|
||||||
|
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
|
||||||
|
* matching common 8-char grid square tools. Pass precision = 6 for the classic
|
||||||
|
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
|
||||||
|
*/
|
||||||
|
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
|
||||||
if (!isValidPosition(latitude, longitude)) return null
|
if (!isValidPosition(latitude, longitude)) return null
|
||||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
val newLongitude = longitude + 180
|
||||||
val newLatitude = latitude + 90
|
val newLatitude = latitude + 90
|
||||||
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
|
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
||||||
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
||||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
||||||
val latSecond = (newLatitude % 10).toInt()
|
val latSecond = (newLatitude % 10).toInt()
|
||||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
||||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
||||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||||
|
if (precision < 8) return qth
|
||||||
|
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
|
||||||
|
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
|
||||||
|
val qth8 = "$qth$lonFourth$latFourth"
|
||||||
|
if (precision < 10) return qth8
|
||||||
|
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
|
||||||
|
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
|
||||||
|
return "$qth8$lonFifth$latFifth"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||||
@@ -51,5 +98,49 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidLocator(locator: String): Boolean {
|
private fun isValidLocator(locator: String): Boolean {
|
||||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
|
||||||
|
* A 4-char input is returned as-is when valid.
|
||||||
|
*/
|
||||||
|
fun qthToSquare(locator: String): String {
|
||||||
|
val upper = locator.trim().uppercase()
|
||||||
|
return when {
|
||||||
|
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
|
||||||
|
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
|
||||||
|
else -> "----"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
|
||||||
|
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
|
||||||
|
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
||||||
|
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
|
||||||
|
*/
|
||||||
|
fun qthNeighbors(square: String): List<String> {
|
||||||
|
if (square.length != 4) return emptyList()
|
||||||
|
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||||
|
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||||
|
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
|
||||||
|
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
for (dLat in 1 downTo -1) { // north -> south
|
||||||
|
for (dLon in -1..1) { // west -> east
|
||||||
|
var lf = lonField
|
||||||
|
var tf = latField
|
||||||
|
var ls = lonSquare + dLon
|
||||||
|
var ts = latSquare + dLat
|
||||||
|
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
|
||||||
|
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
|
||||||
|
lf = (lf + 18) % 18
|
||||||
|
tf = (tf + 18) % 18
|
||||||
|
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
|
||||||
+15
-85
@@ -3,11 +3,7 @@ package com.rtbishop.look4sat.core.domain
|
|||||||
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.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.*
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DopplerFrequencyCalculatorTest {
|
class DopplerFrequencyCalculatorTest {
|
||||||
@@ -17,15 +13,12 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
upHigh: Long = 145_500_000L,
|
upHigh: Long = 145_500_000L,
|
||||||
downLow: Long = 435_000_000L,
|
downLow: Long = 435_000_000L,
|
||||||
downHigh: Long? = 435_500_000L,
|
downHigh: Long? = 435_500_000L,
|
||||||
inverted: Boolean = false,
|
inverted: Boolean = false
|
||||||
info: String = "Linear Transponder",
|
|
||||||
downlinkMode: String? = "USB",
|
|
||||||
uplinkMode: String? = "LSB"
|
|
||||||
) = SatRadio(
|
) = SatRadio(
|
||||||
uuid = "linear", info = info, isAlive = true,
|
uuid = "linear", info = "Linear Transponder", isAlive = true,
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun fmTransponder() = SatRadio(
|
private fun fmTransponder() = SatRadio(
|
||||||
@@ -55,34 +48,14 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
|
||||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
|
||||||
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
|
||||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
|
||||||
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
|
||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
|
||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(0.0)
|
val orbitalPos = pos(0.0)
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
|
assertTrue(uplink!! > 0)
|
||||||
|
// With zero Doppler, result equals mapDownlinkToUplink output
|
||||||
assertEquals(145_200_000L, uplink)
|
assertEquals(145_200_000L, uplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,86 +70,42 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(7.0)
|
val orbitalPos = pos(7.0) // ~7 km/s receding
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
|
// Uplink freq should be Doppler shifted UP (compensating for receding)
|
||||||
assertTrue(uplink!! > 145_200_000L)
|
assertTrue(uplink!! > 145_200_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
|
||||||
val orbitalPos = pos()
|
val orbitalPos = pos()
|
||||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||||
assertNull(result)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
|
||||||
val orbitalPos = pos()
|
val orbitalPos = pos()
|
||||||
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||||
assertNull(result)
|
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
|
@Test
|
||||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
val orbitalPos = pos(0.0)
|
val orbitalPos = pos(0.0)
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
|
// Inverted: offset from high end → maps to high end of uplink
|
||||||
assertEquals(145_300_000L, uplink)
|
assertEquals(145_300_000L, uplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_roundTrip() {
|
fun computeUplinkFromDownlink_roundTrip() {
|
||||||
|
// downlink → uplink → downlink should round-trip
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(3.5)
|
val orbitalPos = pos(3.5)
|
||||||
val originalDownlink = 435_250_000L
|
val originalDownlink = 435_250_000L
|
||||||
@@ -184,6 +113,7 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||||
assertNotNull(roundTripDownlink)
|
assertNotNull(roundTripDownlink)
|
||||||
|
// Doppler round-trip: small residual due to freq-dependent Doppler
|
||||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class QthConverterTest {
|
class QthConverterTest {
|
||||||
@@ -26,21 +28,36 @@ class QthConverterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid QTH returns correct POS`() {
|
fun `Given valid QTH returns correct POS`() {
|
||||||
var result = qthToPosition("io91VL39FX")
|
var result = qthToPosition("io91VL39FX")
|
||||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
||||||
result = qthToPosition("gf15vc")
|
result = qthToPosition("gf15vc")
|
||||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
||||||
|
// 8-char locators: finer 30" x 15" cell center
|
||||||
|
result = qthToPosition("io91vl47")
|
||||||
|
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
||||||
|
result = qthToPosition("jn58td25")
|
||||||
|
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid QTH returns null`() {
|
fun `Given invalid QTH returns null`() {
|
||||||
assert(qthToPosition("ZZ00zz") == null)
|
assert(qthToPosition("ZZ00zz") == null)
|
||||||
assert(qthToPosition("JN58") == null)
|
assert(qthToPosition("JN58") == null)
|
||||||
|
assert(qthToPosition("io9") == null)
|
||||||
|
assert(qthToPosition("IO91VL7") == null)
|
||||||
|
assert(qthToPosition("IO91VL4X") == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid POS returns correct QTH`() {
|
fun `Given valid POS returns correct QTH`() {
|
||||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
// default precision is 8 chars
|
||||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||||
|
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||||
|
// 6-char precision still available for backwards compatibility
|
||||||
|
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||||
|
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||||
|
// 10-char precision
|
||||||
|
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||||
|
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -48,4 +65,61 @@ class QthConverterTest {
|
|||||||
assert(positionToQth(91.0542, -170.1142) == null)
|
assert(positionToQth(91.0542, -170.1142) == null)
|
||||||
assert(positionToQth(89.0542, -240.1142) == null)
|
assert(positionToQth(89.0542, -240.1142) == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given boundary POS stays in valid grid`() {
|
||||||
|
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||||
|
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||||
|
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
|
||||||
|
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||||
|
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||||
|
val positions = listOf(
|
||||||
|
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
|
||||||
|
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
|
||||||
|
)
|
||||||
|
positions.forEach { (lat, lon) ->
|
||||||
|
val qth = positionToQth(lat, lon, 8)
|
||||||
|
val pos = qthToPosition(qth!!)
|
||||||
|
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
|
||||||
|
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given square returns correct 3x3 neighbors`() {
|
||||||
|
// Reference grid from the QTH定位器 screenshot: OL42
|
||||||
|
val neighbors = qthNeighbors("OL42")
|
||||||
|
assert(neighbors == listOf(
|
||||||
|
"OL33", "OL43", "OL53",
|
||||||
|
"OL32", "OL42", "OL52",
|
||||||
|
"OL31", "OL41", "OL51"
|
||||||
|
)) { "OL42 grid mismatch: $neighbors" }
|
||||||
|
// Center cell must be the input itself
|
||||||
|
assert(neighbors[4] == "OL42")
|
||||||
|
// 9 cells, all distinct
|
||||||
|
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given boundary square wraps fields correctly`() {
|
||||||
|
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
||||||
|
val sw = qthNeighbors("AA00")
|
||||||
|
assert(sw.size == 9 && sw.toSet().size == 9)
|
||||||
|
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||||
|
// North-east corner: RR99 wraps to AA00
|
||||||
|
val ne = qthNeighbors("RR99")
|
||||||
|
assert(ne.size == 9 && ne.toSet().size == 9)
|
||||||
|
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||||
|
// Field boundary: IO91's east neighbors cross into J field
|
||||||
|
val london = qthNeighbors("IO91")
|
||||||
|
assert(london[2] == "JO02" && london[5] == "JO01")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given full locator returns square part`() {
|
||||||
|
assert(qthToSquare("OL42ih45") == "OL42")
|
||||||
|
assert(qthToSquare("io91VL39FX") == "IO91")
|
||||||
|
assert(qthToSquare("JN58") == "JN58")
|
||||||
|
assert(qthToSquare("garbage!!") == "----")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
class CwDecoderTest {
|
||||||
|
|
||||||
|
// --- Morse table ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun morseToChar_basicLetters() {
|
||||||
|
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
|
||||||
|
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
|
||||||
|
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun morseToChar_numbers() {
|
||||||
|
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
|
||||||
|
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun morseToChar_unknown_returnsNull() {
|
||||||
|
assertNull(CwBayesianDecoder.morseToChar("......."))
|
||||||
|
assertNull(CwBayesianDecoder.morseToChar(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FFT ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fft_magnitudeSpectrum_detectsTone() {
|
||||||
|
val fft = CwFFT(256)
|
||||||
|
val sampleRate = 8000f
|
||||||
|
val freq = 700f
|
||||||
|
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
|
||||||
|
val mag = fft.magnitudeSpectrum(buffer)
|
||||||
|
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
|
||||||
|
var maxBin = 0
|
||||||
|
var maxVal = 0f
|
||||||
|
for (i in mag.indices) {
|
||||||
|
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
|
||||||
|
}
|
||||||
|
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
|
||||||
|
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fft_magnitudeSpectrum_silence_isFlat() {
|
||||||
|
val fft = CwFFT(256)
|
||||||
|
val buffer = FloatArray(256) { 0f }
|
||||||
|
val mag = fft.magnitudeSpectrum(buffer)
|
||||||
|
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fft_rejectsWrongSize() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spectrogram ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_addSamples_updatesEnergy() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val freq = 700f
|
||||||
|
// Feed multiple frames to stabilize energy normalization
|
||||||
|
for (i in 0..5) {
|
||||||
|
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
|
||||||
|
spec.addSamples(buffer)
|
||||||
|
}
|
||||||
|
val col = spec.getCurrentColumn()
|
||||||
|
val peakBin = spec.findPeakBin()
|
||||||
|
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_findPeakBin_returnsValidBin() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
// Add multiple frames of 700 Hz tone
|
||||||
|
for (i in 0..5) {
|
||||||
|
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||||
|
spec.addSamples(buffer)
|
||||||
|
}
|
||||||
|
val peakBin = spec.findPeakBin()
|
||||||
|
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_freqToBin_roundtrip() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val freq = 700f
|
||||||
|
val bin = spec.freqToBin(freq)
|
||||||
|
val backFreq = spec.binToFreq(bin)
|
||||||
|
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_getBinEnergy_returnsCorrectLength() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val energy = spec.getBinEnergy(0, 10)
|
||||||
|
assertEquals(10, energy.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_reset() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
spec.addSamples(FloatArray(256) { 1f })
|
||||||
|
spec.reset()
|
||||||
|
assertEquals(-1, spec.findPeakBin())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bayesian decoder ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processTone_dit() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// At 20 WPM, dot = 60 ms
|
||||||
|
val result = decoder.processTone(60f)
|
||||||
|
assertEquals('0', result.symbol)
|
||||||
|
assertTrue("Dit probability should be positive", result.probability > 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processTone_dash() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// Dash = 3 * dot = 180 ms
|
||||||
|
val result = decoder.processTone(180f)
|
||||||
|
assertEquals('1', result.symbol)
|
||||||
|
assertTrue("Dash probability should be positive", result.probability > 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processTone_unknown_returnsNull() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// Very long tone — low probability for both dit and dash
|
||||||
|
val result = decoder.processTone(5000f)
|
||||||
|
assertNull(result.symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processGap_interChar_returnsChar() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f) // dit
|
||||||
|
decoder.processTone(60f) // dit
|
||||||
|
decoder.processTone(60f) // dit
|
||||||
|
// 3 dots = "000" = 'S'
|
||||||
|
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
|
||||||
|
assertEquals('S', char)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processGap_wordGap_addsSpace() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f) // dit = 'E'
|
||||||
|
decoder.processGap(180f) // inter-char gap
|
||||||
|
// Now word gap
|
||||||
|
val space = decoder.processGap(420f) // 7 * dot
|
||||||
|
assertEquals(' ', space)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_decodedText_accumulates() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f) // dit = 'E'
|
||||||
|
decoder.processGap(180f) // inter-char
|
||||||
|
assertTrue(decoder.decodedText.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_reset() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f)
|
||||||
|
decoder.reset()
|
||||||
|
assertEquals("", decoder.decodedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_getSpeed() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// Send 3 dits at 20 WPM (60 ms each)
|
||||||
|
decoder.processTone(60f)
|
||||||
|
decoder.processTone(60f)
|
||||||
|
decoder.processTone(60f)
|
||||||
|
val speed = decoder.getSpeed()
|
||||||
|
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel tracker ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_initialState() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
val channels = tracker.update()
|
||||||
|
assertTrue("No channels should be active initially", channels.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_detectsTone() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
// Feed a tone
|
||||||
|
for (i in 0..5) {
|
||||||
|
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
val channels = tracker.update()
|
||||||
|
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_bestChannel() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
for (i in 0..5) {
|
||||||
|
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
tracker.update()
|
||||||
|
val best = tracker.getBestChannel()
|
||||||
|
assertNotNull("Best channel should exist", best)
|
||||||
|
if (best != null) assertTrue(best.frequency in 600f..800f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_reset() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
for (i in 0..5) {
|
||||||
|
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
tracker.update()
|
||||||
|
tracker.reset()
|
||||||
|
assertNull(tracker.getBestChannel())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Full decoder ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_initialState() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
assertEquals("", decoder.decodedTextFlow.value)
|
||||||
|
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_processSilence_doesNotCrash() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
decoder.processBuffer(FloatArray(256) { 0f })
|
||||||
|
assertEquals("", decoder.decodedTextFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_processNoise_doesNotCrash() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
|
||||||
|
assertNotNull(decoder.decodedTextFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_processTone_doesNotCrash() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
for (i in 0..20) {
|
||||||
|
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
assertNotNull(decoder.decodedTextFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_reset() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
decoder.processBuffer(FloatArray(256) { 1f })
|
||||||
|
decoder.resetDecoder()
|
||||||
|
assertEquals("", decoder.decodedTextFlow.value)
|
||||||
|
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_withFixedPitch() {
|
||||||
|
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
|
||||||
|
assertEquals(700f, decoder.estimatedPitch.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-21
@@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
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
|
||||||
@@ -47,7 +46,6 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ModalBottomSheet
|
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.SheetValue
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
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
|
||||||
@@ -145,7 +143,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
|
||||||
@@ -159,16 +157,13 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
|
||||||
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = pass.name,
|
text = pass.name,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(end = 6.dp)
|
.padding(end = 6.dp)
|
||||||
.infiniteMarquee(),
|
.infiniteMarquee(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -214,11 +209,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(
|
||||||
@@ -309,8 +299,7 @@ fun SharedDialog(
|
|||||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||||
content: @Composable (padding: Dp) -> Unit
|
content: @Composable (padding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val dismissible = onCancel != null
|
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
||||||
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -344,14 +333,10 @@ fun SharedDialog(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DialogShell(
|
private fun DialogShell(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
dismissible: Boolean = true,
|
|
||||||
content: @Composable (padding: Dp) -> Unit
|
content: @Composable (padding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val sheetState = rememberModalBottomSheetState(
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
skipPartiallyExpanded = true,
|
|
||||||
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
|
|
||||||
)
|
|
||||||
val stopSheetFling = remember {
|
val stopSheetFling = remember {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
@@ -366,10 +351,10 @@ private fun DialogShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = if (dismissible) onDismissRequest else { {} },
|
onDismissRequest = onDismissRequest,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = MaterialTheme.shapes.medium,
|
||||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@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)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="96"
|
||||||
|
android:viewportHeight="96">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
|
||||||
|
</vector>
|
||||||
@@ -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,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="96"
|
||||||
|
android:viewportHeight="96">
|
||||||
|
|
||||||
|
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
|
||||||
|
</vector>
|
||||||
@@ -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,7 +26,7 @@
|
|||||||
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>
|
||||||
@@ -39,7 +35,7 @@
|
|||||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||||
<string name="pass_filter_invert_time">Invertir ventana AOS</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_filter_deep_space">DeepSpace (período >225min)</string>
|
||||||
<string name="pass_modes_title">Seleccionar modos</string>
|
<string name="pass_modes_title">Tipo de modulación</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">
|
||||||
@@ -100,11 +96,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,52 +110,39 @@
|
|||||||
<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_tle_switch">URL TLE</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</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_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
|||||||
@@ -13,23 +13,19 @@
|
|||||||
<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>
|
||||||
@@ -38,7 +34,7 @@
|
|||||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||||
<string name="pass_modes_title">Выберите режимы</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">
|
||||||
@@ -99,11 +95,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,30 +109,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_tle_switch">URL TLE</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom 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">Вывод данных</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>
|
||||||
@@ -144,22 +126,22 @@
|
|||||||
<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_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
|||||||
@@ -13,23 +13,19 @@
|
|||||||
<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 -->
|
||||||
@@ -39,7 +35,7 @@
|
|||||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||||
<string name="pass_modes_title">මූර්ජන තෝරන්න</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">
|
||||||
@@ -100,11 +96,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,52 +110,39 @@
|
|||||||
<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_tle_switch">TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom 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_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
|||||||
@@ -14,23 +14,19 @@
|
|||||||
<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>
|
||||||
@@ -40,7 +36,7 @@
|
|||||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</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_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>
|
||||||
@@ -55,11 +51,12 @@
|
|||||||
\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</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
* Roaming sayfası QTH定位器 2.0\'dan birebir taşındı: 8 haneli konum, 3x3 ızgara, kırmızı işaret, canlı GPS
|
||||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
\n\n* İçeriğin sistem durum/gezinme çubuklarıyla çakışması düzeltildi; ızgara sütunları referans düzenle eşleşiyor
|
||||||
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
|
\n\n* Roaming sayfası renkleri uygulama temasını takip eder - kırmızı gece modunda tamamen görünür
|
||||||
\n* Added multiple performance tweaks, improved passes calculation
|
\n\n* Ayarlar: sıkışan anahtar satırları ve teşekkür aralığı düzeltildi
|
||||||
\n* Fixed UTC pass time problem mentioned in issue #201
|
\n\n* Uygulama adı artık Look4Sat Pro
|
||||||
|
\n\n* Ayarlar: "İçe aktar" düğmesi "Özel URL" olarak yeniden adlandırıldı - TLE/alıcı-verici kaynak URL\'leri doğrudan düzenlenebilir
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -107,7 +104,7 @@
|
|||||||
<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 Pro 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=com.rtbishop.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>
|
||||||
@@ -131,44 +128,43 @@
|
|||||||
<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>
|
||||||
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
||||||
<string name="prefs_data_update">Güncelle</string>
|
<string name="prefs_data_update">Güncelle</string>
|
||||||
<string name="prefs_data_import">İçe aktar</string>
|
<string name="prefs_data_import">Özel URL</string>
|
||||||
<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_tle_switch">TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Özel 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_reset_url">Varsayılan URL\'ye sıfırla</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>
|
||||||
@@ -176,36 +172,38 @@
|
|||||||
<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_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</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_mid">Orta %1$d-%2$d°</string>
|
||||||
<string name="prefs_highlight_high">Yüksek > %1$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">BG7NTA ve orijinal yazar teşekkür eder</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat users and contributors!
|
• Look4Sat users and contributors!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n• David A. B. Johnson (predict4java)
|
||||||
\n• Dave Moten (predict4java)
|
\n• Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
|
\n• BA7OPF (pass matching feature)
|
||||||
|
\n• BG7NTA</string>
|
||||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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,7 +26,7 @@
|
|||||||
У цьому додатку перелічено понад 9000 супутників.
|
У цьому додатку перелічено понад 9000 супутників.
|
||||||
Немає сенсу відстежувати їх усі одночасно.
|
Немає сенсу відстежувати їх усі одночасно.
|
||||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||||
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||||
@@ -39,7 +35,7 @@
|
|||||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||||
<string name="pass_modes_title">Виберіть режими</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">
|
||||||
@@ -100,11 +96,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,52 +110,39 @@
|
|||||||
<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_tle_switch">URL TLE</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom 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="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_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
|||||||
@@ -9,23 +9,21 @@
|
|||||||
<string name="nav_sat">卫星</string>
|
<string name="nav_sat">卫星</string>
|
||||||
<string name="nav_pass">过境</string>
|
<string name="nav_pass">过境</string>
|
||||||
<string name="nav_radar">雷达</string>
|
<string name="nav_radar">雷达</string>
|
||||||
|
<string name="nav_mutual">匹配</string>
|
||||||
|
<string name="nav_roaming">漫游</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<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">此应用收录了超过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>
|
||||||
@@ -34,13 +32,22 @@
|
|||||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||||
<string name="pass_modes_title">选择模式</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\!</string>
|
||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
|
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||||
|
<string name="pass_whatsnew_message">
|
||||||
|
* 漫游页完整移植 QTH定位器 2.0:8 位网格、3x3 九宫格、红点查表定位、实时 GPS
|
||||||
|
\n\n* 修复内容被系统状态栏/导航栏遮挡;九宫格列宽与原版一致
|
||||||
|
\n\n* 漫游页配色跟随应用主题,红色夜间模式下清晰可见
|
||||||
|
\n\n* 设置:修复开关行挤压与感谢列表间距
|
||||||
|
\n\n* 应用名现为 Look4Sat Pro
|
||||||
|
\n\n* 设置:导入文件按钮更名为自定义URL,可直接编辑 TLE/收发机数据源地址
|
||||||
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">后退</string>
|
<string name="radar_back">后退</string>
|
||||||
@@ -60,9 +67,17 @@
|
|||||||
<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_calc">多普勒频率计算器</string>
|
||||||
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
|
||||||
|
|
||||||
|
<string name="radar_cw_decoder">CW 解码器</string>
|
||||||
|
<string name="radar_cw_start">开始</string>
|
||||||
|
<string name="radar_cw_stop">停止</string>
|
||||||
|
<string name="radar_cw_reset">清空</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
@@ -95,43 +110,34 @@
|
|||||||
<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>
|
||||||
<string name="prefs_data_radios">收发器:%s</string>
|
<string name="prefs_data_radios">收发器:%s</string>
|
||||||
<string name="prefs_data_update">在线更新</string>
|
<string name="prefs_data_update">在线更新</string>
|
||||||
<string name="prefs_data_import">导入文件</string>
|
<string name="prefs_data_import">自定义URL</string>
|
||||||
<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_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</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_tle_switch">TLE URL</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_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="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>
|
||||||
@@ -139,7 +145,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>
|
||||||
@@ -149,26 +155,39 @@
|
|||||||
<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>
|
<!-- Radio Control -->
|
||||||
<string name="prefs_other_switch_utc">UTC 时间</string>
|
<string name="nav_radiocontrol">电台控制</string>
|
||||||
<string name="prefs_other_switch_update">自动更新数据</string>
|
<string name="rc_settings_title">CAT 电台控制</string>
|
||||||
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
<string name="rc_radio_model">电台型号</string>
|
||||||
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
|
||||||
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
|
||||||
|
<string name="rc_tx_name_hint">发射电台名称</string>
|
||||||
|
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||||
|
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||||
|
|
||||||
|
<string name="prefs_other_title">其他设置</string>
|
||||||
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
|
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||||
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">仰角高亮</string>
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||||
<string name="prefs_highlight_high">高 > %1$d°</string>
|
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">我要感谢:</string>
|
<string name="prefs_outro_title">BG7NTA 与原作者感谢:</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat 所有用户及贡献者!
|
• Look4Sat 所有用户及贡献者!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n• David A. B. Johnson (predict4java)
|
||||||
\n• Dave Moten (predict4java)
|
\n• Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
|
\n• xdsopl 和 Robot36 贡献者!
|
||||||
|
\n• BA7OPF(过境匹配功能)
|
||||||
|
\n• BG7NTA</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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 Pro</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>
|
||||||
@@ -10,27 +10,25 @@
|
|||||||
<string name="nav_sat">Satellites</string>
|
<string name="nav_sat">Satellites</string>
|
||||||
<string name="nav_pass">Passes</string>
|
<string name="nav_pass">Passes</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_mutual">Match</string>
|
||||||
|
<string name="nav_roaming">Roaming</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</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>
|
||||||
@@ -40,7 +38,7 @@
|
|||||||
<string name="pass_filter_invert_time">Invert 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>
|
||||||
@@ -55,33 +53,14 @@
|
|||||||
\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</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added small tweaks to Sources and strings. Added official ISS source (#225)
|
* Roaming page ported verbatim from QTH定位器 2.0: 8-char locator, 3x3 grid, red marker lookup, live GPS
|
||||||
\n* Implemented AOS window and elevation highlight filters (#226)
|
\n\n* Fixed content overlapping the system status/navigation bars; grid columns now match the reference layout
|
||||||
\n* Added cleartext traffic support for custom TLE URLs (#227)
|
\n\n* Roaming page colors follow the app theme - fully visible in red night mode
|
||||||
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
|
\n\n* Settings: fixed squeezed toggle rows and credits spacing
|
||||||
\n* Added support for Icom IC-705 CAT by ruilvo (#229)
|
\n\n* App name is now Look4Sat Pro
|
||||||
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
|
\n\n* Settings: "Import" button renamed to "Custom URL" - edit TLE/transceiver source URLs directly
|
||||||
\n* Localized pass date and time formats for Chinese by atsunatsu (#231)
|
|
||||||
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
|
|
||||||
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
|
|
||||||
\n* Replaced the types selection dialog with modes selection to avoid confusion
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- AMSAT Status screen -->
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">AMSAT Satellite Status</string>
|
|
||||||
<string name="amsat_refresh">Refresh</string>
|
|
||||||
<string name="amsat_retry">Retry</string>
|
|
||||||
<string name="amsat_updated">Updated:</string>
|
|
||||||
<string name="amsat_active">Active</string>
|
|
||||||
<string name="amsat_tlm">TLM/Beacon</string>
|
|
||||||
<string name="amsat_not_heard">Not Heard</string>
|
|
||||||
<string name="amsat_conflict">Conflicting</string>
|
|
||||||
<string name="amsat_name">Name</string>
|
|
||||||
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
|
||||||
<string name="amsat_no_reports">No reports for this period</string>
|
|
||||||
<string name="amsat_close">Close</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- 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>
|
||||||
@@ -107,9 +86,16 @@
|
|||||||
<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_calc">Doppler Frequency Calculator</string>
|
||||||
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</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_rx_hint">Enter RX freq (MHz)</string>
|
||||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
|
||||||
|
|
||||||
|
<string name="radar_cw_decoder">CW Decoder</string>
|
||||||
|
<string name="radar_cw_start">Start</string>
|
||||||
|
<string name="radar_cw_stop">Stop</string>
|
||||||
|
<string name="radar_cw_reset">Clear</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
@@ -130,7 +116,7 @@
|
|||||||
<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 Pro 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=com.rtbishop.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>
|
||||||
@@ -154,27 +140,28 @@
|
|||||||
<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>
|
||||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
<string name="prefs_data_radios">Transceivers: %s</string>
|
||||||
<string name="prefs_data_update">Update</string>
|
<string name="prefs_data_update">Update</string>
|
||||||
<string name="prefs_data_import">Import</string>
|
<string name="prefs_data_import">Custom URL</string>
|
||||||
<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_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_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom sources</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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_reset_url">Reset to default URL</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>
|
||||||
@@ -183,15 +170,15 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -199,31 +186,29 @@
|
|||||||
<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_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_title">Elevation highlight</string>
|
||||||
<string name="prefs_highlight_low">Low < %1$d°</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_mid">Mid %1$d–%2$d°</string>
|
||||||
<string name="prefs_highlight_high">High > %1$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">BG7NTA & the original author would like to thank</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
* Look4Sat users and contributors!
|
* Look4Sat users and contributors!
|
||||||
\n* David A. B. Johnson (predict4java)
|
\n* David A. B. Johnson (predict4java)
|
||||||
@@ -232,6 +217,8 @@
|
|||||||
\n* Dr T.S. Kelso (Celestrak)
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
\n* Libre Space Foundation (SatNOGS)
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
\n* xdsopl and Robot36 contributors!
|
\n* xdsopl and Robot36 contributors!
|
||||||
|
\n* BA7OPF (pass matching feature)
|
||||||
|
\n* BG7NTA
|
||||||
</string>
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,2 @@
|
|||||||
* Added ARISS source, tweaked SSTV sensitivity #225
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
* Implemented AOS window and elev filter #226
|
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
* Added cleartext traffic support for TLEs #227
|
|
||||||
* Fixed OMM import, tweaked error messages #228
|
|
||||||
* Added support for Icom CAT by ruilvo #229
|
|
||||||
* Added tweakable Radar offset by qwqtoday #230
|
|
||||||
* Localized date/time formats by atsunatsu #231
|
|
||||||
* Added linear radio Doppler calc by atsunatsu #232
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.mutual"
|
||||||
|
}
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draggable dual-station elevation curve chart.
|
||||||
|
* Controlled component: [progress] (0..1) and [onProgressChange] are owned by the parent,
|
||||||
|
* so the same time cursor can be shared with the radar track view.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ElevationCurveChart(
|
||||||
|
samples: List<Pair<Long, Pair<Double, Double>>>,
|
||||||
|
startTime: Long,
|
||||||
|
endTime: Long,
|
||||||
|
maxElev: Double,
|
||||||
|
progress: Float = 0.5f,
|
||||||
|
onProgressChange: (Float) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (samples.isEmpty()) return
|
||||||
|
|
||||||
|
val colorA = MaterialTheme.colorScheme.primary
|
||||||
|
val colorB = MaterialTheme.colorScheme.tertiary
|
||||||
|
val gridColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
val colorAArgb = colorA.toArgb()
|
||||||
|
val colorBArgb = colorB.toArgb()
|
||||||
|
val textColorArgb = textColor.toArgb()
|
||||||
|
val onSurfaceArgb = onSurfaceColor.toArgb()
|
||||||
|
val gridColorArgb = gridColor.toArgb()
|
||||||
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
onProgressChange((offset.x / size.width.toFloat()).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures { change, _ ->
|
||||||
|
onProgressChange((change.position.x / size.width.toFloat()).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val chartWidth = size.width
|
||||||
|
val chartHeight = size.height
|
||||||
|
val padding = 40f
|
||||||
|
val plotLeft = padding
|
||||||
|
val plotRight = chartWidth - 10f
|
||||||
|
val plotTop = 10f
|
||||||
|
val plotBottom = chartHeight - padding
|
||||||
|
val plotWidth = plotRight - plotLeft
|
||||||
|
val plotHeight = plotBottom - plotTop
|
||||||
|
|
||||||
|
if (plotWidth <= 0f || plotHeight <= 0f) return@Canvas
|
||||||
|
|
||||||
|
val minTime = startTime
|
||||||
|
val maxTime = endTime
|
||||||
|
val timeRange = (maxTime - minTime).toFloat()
|
||||||
|
if (timeRange <= 0f) return@Canvas
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
val gridSteps = 4
|
||||||
|
for (i in 0..gridSteps) {
|
||||||
|
val y = plotBottom - (plotHeight * i / gridSteps)
|
||||||
|
drawLine(gridColor, Offset(plotLeft, y), Offset(plotRight, y), strokeWidth = 1f)
|
||||||
|
val elev = (maxElev * i / gridSteps).roundToInt()
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
"$elev°", 2f, y + 4f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = textColorArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time axis
|
||||||
|
val timeSteps = 4
|
||||||
|
for (i in 0..timeSteps) {
|
||||||
|
val x = plotLeft + (plotWidth * i / timeSteps)
|
||||||
|
val t = startTime + ((endTime - startTime) * i / timeSteps)
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
timeFormat.format(Date(t)), x - 20f, chartHeight - 2f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = textColorArgb
|
||||||
|
textSize = 22f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation curves (smooth cubic Bezier)
|
||||||
|
val pathA = Path()
|
||||||
|
val pathB = Path()
|
||||||
|
val ptsA = mutableListOf<Offset>()
|
||||||
|
val ptsB = mutableListOf<Offset>()
|
||||||
|
|
||||||
|
for ((time, elev) in samples) {
|
||||||
|
val x = plotLeft + ((time - minTime).toFloat() / timeRange) * plotWidth
|
||||||
|
val yA = plotBottom - ((elev.first.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
|
||||||
|
val yB = plotBottom - ((elev.second.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
|
||||||
|
ptsA.add(Offset(x, yA))
|
||||||
|
ptsB.add(Offset(x, yB))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ptsA.size >= 2) {
|
||||||
|
pathA.moveTo(ptsA[0].x, ptsA[0].y)
|
||||||
|
pathB.moveTo(ptsB[0].x, ptsB[0].y)
|
||||||
|
for (i in 1 until ptsA.size) {
|
||||||
|
val p0a = ptsA.getOrNull(i - 2) ?: ptsA[i - 1]
|
||||||
|
val p1a = ptsA[i - 1]
|
||||||
|
val p2a = ptsA[i]
|
||||||
|
val p3a = ptsA.getOrNull(i + 1) ?: ptsA[i]
|
||||||
|
val cp1a = Offset(p1a.x + (p2a.x - p0a.x) / 6f, p1a.y + (p2a.y - p0a.y) / 6f)
|
||||||
|
val cp2a = Offset(p2a.x - (p3a.x - p1a.x) / 6f, p2a.y - (p3a.y - p1a.y) / 6f)
|
||||||
|
pathA.cubicTo(cp1a.x, cp1a.y, cp2a.x, cp2a.y, p2a.x, p2a.y)
|
||||||
|
|
||||||
|
val p0b = ptsB.getOrNull(i - 2) ?: ptsB[i - 1]
|
||||||
|
val p1b = ptsB[i - 1]
|
||||||
|
val p2b = ptsB[i]
|
||||||
|
val p3b = ptsB.getOrNull(i + 1) ?: ptsB[i]
|
||||||
|
val cp1b = Offset(p1b.x + (p2b.x - p0b.x) / 6f, p1b.y + (p2b.y - p0b.y) / 6f)
|
||||||
|
val cp2b = Offset(p2b.x - (p3b.x - p1b.x) / 6f, p2b.y - (p3b.y - p1b.y) / 6f)
|
||||||
|
pathB.cubicTo(cp1b.x, cp1b.y, cp2b.x, cp2b.y, p2b.x, p2b.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(pathA, colorA, style = Stroke(width = 2.5f))
|
||||||
|
drawPath(pathB, colorB, style = Stroke(width = 2.5f))
|
||||||
|
|
||||||
|
// Drag indicator
|
||||||
|
val dragX = plotLeft + progress * plotWidth
|
||||||
|
val dragTime = startTime + (progress * (endTime - startTime)).toLong()
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
onSurfaceColor,
|
||||||
|
Offset(dragX, plotTop), Offset(dragX, plotBottom),
|
||||||
|
strokeWidth = 2f
|
||||||
|
)
|
||||||
|
|
||||||
|
val dragElev = getElevationAtTime(samples, dragTime)
|
||||||
|
val dragElevA = (dragElev?.first?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
|
||||||
|
val dragElevB = (dragElev?.second?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
|
||||||
|
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
timeFormat.format(Date(dragTime)),
|
||||||
|
dragX - 24f, plotBottom + 16f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = onSurfaceArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
"A:${dragElevA}°",
|
||||||
|
dragX + 6f, plotTop + 16f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = colorAArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
"B:${dragElevB}°",
|
||||||
|
dragX + 6f, plotTop + 42f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = colorBArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
Text("● 站点A", color = colorA, fontSize = 12.sp)
|
||||||
|
Text("● 站点B", color = colorB, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getElevationAtTime(
|
||||||
|
samples: List<Pair<Long, Pair<Double, Double>>>,
|
||||||
|
time: Long
|
||||||
|
): Pair<Double, Double>? {
|
||||||
|
if (samples.isEmpty()) return null
|
||||||
|
if (samples.size == 1) return samples[0].second
|
||||||
|
if (time <= samples[0].first) return samples[0].second
|
||||||
|
if (time >= samples.last().first) return samples.last().second
|
||||||
|
|
||||||
|
var lo = 0
|
||||||
|
var hi = samples.size - 1
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
val mid = (lo + hi) / 2
|
||||||
|
if (samples[mid].first <= time) lo = mid
|
||||||
|
else hi = mid
|
||||||
|
}
|
||||||
|
|
||||||
|
val t0 = samples[lo].first
|
||||||
|
val t1 = samples[hi].first
|
||||||
|
val frac = if (t1 > t0) (time - t0).toFloat() / (t1 - t0).toFloat() else 0f
|
||||||
|
|
||||||
|
val e0 = samples[lo].second
|
||||||
|
val e1 = samples[hi].second
|
||||||
|
return Pair(
|
||||||
|
e0.first + (e1.first - e0.first) * frac,
|
||||||
|
e0.second + (e1.second - e0.second) * frac
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mutual pass where a satellite is visible from two stations simultaneously.
|
||||||
|
*
|
||||||
|
* @param catNum Satellite catalog number
|
||||||
|
* @param name Satellite name
|
||||||
|
* @param startTime Common AOS time (millis)
|
||||||
|
* @param endTime Common LOS time (millis)
|
||||||
|
* @param maxElevationA Peak elevation for station A
|
||||||
|
* @param maxElevationB Peak elevation for station B
|
||||||
|
* @param elevationSamples List of (time, (elevationA, elevationB)) pairs
|
||||||
|
* @param trackSamples List of radar-track samples (azimuth/elevation for both stations, degrees)
|
||||||
|
*/
|
||||||
|
data class MutualPass(
|
||||||
|
val catNum: Int,
|
||||||
|
val name: String,
|
||||||
|
val startTime: Long,
|
||||||
|
val endTime: Long,
|
||||||
|
val maxElevationA: Double,
|
||||||
|
val maxElevationB: Double,
|
||||||
|
val elevationSamples: List<Pair<Long, Pair<Double, Double>>>,
|
||||||
|
val trackSamples: List<TrackSample> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One radar-track sample: satellite position as seen from both stations.
|
||||||
|
* All angles are in degrees.
|
||||||
|
*/
|
||||||
|
data class TrackSample(
|
||||||
|
val time: Long,
|
||||||
|
val azimuthA: Double,
|
||||||
|
val elevationA: Double,
|
||||||
|
val azimuthB: Double,
|
||||||
|
val elevationB: Double
|
||||||
|
)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
private const val CIRCLES = 3 // 30°, 60°, 90° rings
|
||||||
|
private const val STROKE_WIDTH = 2.5f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polar radar chart showing the satellite track as seen from both stations
|
||||||
|
* on one plot. Station A is drawn solid, station B dashed.
|
||||||
|
* Controlled: [progress] (0..1) and [onProgressChange] are owned by the parent
|
||||||
|
* so the time cursor can be shared with the elevation curve chart.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MutualRadarView(
|
||||||
|
trackSamples: List<TrackSample>,
|
||||||
|
progress: Float = 0.5f,
|
||||||
|
labelA: String = "站点A",
|
||||||
|
labelB: String = "站点B",
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (trackSamples.isEmpty()) return
|
||||||
|
|
||||||
|
val colorA = MaterialTheme.colorScheme.primary
|
||||||
|
val colorB = MaterialTheme.colorScheme.tertiary
|
||||||
|
val gridColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
|
||||||
|
// Current-time samples (both stations' position at the shared cursor)
|
||||||
|
val t0 = trackSamples.first().time
|
||||||
|
val t1 = trackSamples.last().time
|
||||||
|
val selectedTime = t0 + ((t1 - t0) * progress).toLong()
|
||||||
|
val visibleSamples = trackSamples.filter { it.time <= selectedTime }
|
||||||
|
val currentSample = visibleSamples.lastOrNull() ?: trackSamples.first()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
val radius = size.minDimension / 2f * 0.92f
|
||||||
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
|
|
||||||
|
// Grid: elevation rings + azimuth spokes + cardinal labels
|
||||||
|
drawRadarGrid(center, radius, gridColor, textColor, measurer)
|
||||||
|
|
||||||
|
// Track paths (only the above-horizon arc, split at the 0/360° azimuth wrap)
|
||||||
|
val pathA = Path()
|
||||||
|
val pathB = Path()
|
||||||
|
var firstVisibleA = true
|
||||||
|
var firstVisibleB = true
|
||||||
|
var lastAzimA: Double? = null
|
||||||
|
var lastAzimB: Double? = null
|
||||||
|
trackSamples.forEach { sample ->
|
||||||
|
if (sample.elevationA > 0f) {
|
||||||
|
val p = sph2Cart(center, sample.azimuthA, sample.elevationA, radius)
|
||||||
|
val wrapA = lastAzimA != null && kotlin.math.abs(azimuthDelta(sample.azimuthA - lastAzimA!!)) > 180.0
|
||||||
|
if (firstVisibleA || wrapA) {
|
||||||
|
pathA.moveTo(p.x, p.y)
|
||||||
|
firstVisibleA = false
|
||||||
|
} else {
|
||||||
|
pathA.lineTo(p.x, p.y)
|
||||||
|
}
|
||||||
|
lastAzimA = sample.azimuthA
|
||||||
|
}
|
||||||
|
if (sample.elevationB > 0f) {
|
||||||
|
val p = sph2Cart(center, sample.azimuthB, sample.elevationB, radius)
|
||||||
|
val wrapB = lastAzimB != null && kotlin.math.abs(azimuthDelta(sample.azimuthB - lastAzimB!!)) > 180.0
|
||||||
|
if (firstVisibleB || wrapB) {
|
||||||
|
pathB.moveTo(p.x, p.y)
|
||||||
|
firstVisibleB = false
|
||||||
|
} else {
|
||||||
|
pathB.lineTo(p.x, p.y)
|
||||||
|
}
|
||||||
|
lastAzimB = sample.azimuthB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid line for station A, dashed for station B
|
||||||
|
drawPath(pathA, colorA, style = Stroke(STROKE_WIDTH))
|
||||||
|
drawPath(
|
||||||
|
pathB, colorB,
|
||||||
|
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)))
|
||||||
|
)
|
||||||
|
|
||||||
|
// AOS / LOS markers on both tracks
|
||||||
|
val first = trackSamples.first()
|
||||||
|
val last = trackSamples.last()
|
||||||
|
val aosA = sph2Cart(center, first.azimuthA, first.elevationA, radius)
|
||||||
|
val aosB = sph2Cart(center, first.azimuthB, first.elevationB, radius)
|
||||||
|
val losA = sph2Cart(center, last.azimuthA, last.elevationA, radius)
|
||||||
|
val losB = sph2Cart(center, last.azimuthB, last.elevationB, radius)
|
||||||
|
// AOS: hollow circle, LOS: filled circle
|
||||||
|
if (first.elevationA > 0f) drawCircle(colorA, 7f, aosA, style = Stroke(2.5f))
|
||||||
|
if (first.elevationB > 0f) drawCircle(colorB, 7f, aosB, style = Stroke(2.5f))
|
||||||
|
if (last.elevationA > 0f) drawCircle(colorA, 5f, losA)
|
||||||
|
if (last.elevationB > 0f) drawCircle(colorB, 5f, losB)
|
||||||
|
|
||||||
|
// Shared time-cursor positions (both stations at selectedTime)
|
||||||
|
val cursorA = sph2Cart(center, currentSample.azimuthA, currentSample.elevationA, radius)
|
||||||
|
val cursorB = sph2Cart(center, currentSample.azimuthB, currentSample.elevationB, radius)
|
||||||
|
drawCircle(colorA, 14f, cursorA, style = Stroke(3f))
|
||||||
|
drawCircle(colorA, 6f, cursorA)
|
||||||
|
drawCircle(colorB, 14f, cursorB, style = Stroke(3f))
|
||||||
|
drawCircle(colorB, 6f, cursorB)
|
||||||
|
|
||||||
|
// Center dot
|
||||||
|
drawCircle(gridColor, 4f, center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
Text("● $labelA", color = colorA, fontSize = 12.sp)
|
||||||
|
Text("- - $labelB", color = colorB, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawRadarGrid(
|
||||||
|
center: Offset,
|
||||||
|
radius: Float,
|
||||||
|
color: Color,
|
||||||
|
textColor: Color,
|
||||||
|
measurer: TextMeasurer
|
||||||
|
) {
|
||||||
|
// Elevation rings: 30°, 60°, 90°(center) — outer edge is horizon (0°)
|
||||||
|
val step = radius / CIRCLES
|
||||||
|
for (i in 0 until CIRCLES) {
|
||||||
|
drawCircle(color, radius - step * i, center, style = Stroke(1.5f))
|
||||||
|
}
|
||||||
|
// Cardinal spokes
|
||||||
|
drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), 1.5f)
|
||||||
|
drawLine(color, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), 1.5f)
|
||||||
|
// Diagonal spokes (lighter)
|
||||||
|
val diag = radius * 0.7071f
|
||||||
|
drawLine(
|
||||||
|
color.copy(alpha = 0.5f),
|
||||||
|
Offset(center.x - diag, center.y - diag), Offset(center.x + diag, center.y + diag), 1f
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color.copy(alpha = 0.5f),
|
||||||
|
Offset(center.x - diag, center.y + diag), Offset(center.x + diag, center.y - diag), 1f
|
||||||
|
)
|
||||||
|
|
||||||
|
// Elevation ring labels: 30° on the outer ring, 60° on the middle ring, 90° at center
|
||||||
|
// (outer edge is the 0° horizon). Labels sit just above their ring.
|
||||||
|
val style = TextStyle(color = textColor, fontSize = 11.sp)
|
||||||
|
drawText(measurer, "30°", Offset(center.x + 6f, (center.y - (radius - step)) - 24f), style = style)
|
||||||
|
drawText(measurer, "60°", Offset(center.x + 6f, (center.y - (radius - 2 * step)) - 24f), style = style)
|
||||||
|
drawText(measurer, "90°", Offset(center.x + 6f, center.y - 18f), style = style)
|
||||||
|
// Cardinal labels
|
||||||
|
drawText(measurer, "N", Offset(center.x - 8f, center.y - radius - 20f), style = style)
|
||||||
|
drawText(measurer, "E", Offset(center.x + radius + 4f, center.y - 10f), style = style)
|
||||||
|
drawText(measurer, "S", Offset(center.x - 6f, center.y + radius + 2f), style = style)
|
||||||
|
drawText(measurer, "W", Offset(center.x - radius - 24f, center.y - 10f), style = style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize an azimuth delta (degrees) into the [-180, 180] range. */
|
||||||
|
private fun azimuthDelta(deltaDeg: Double): Double {
|
||||||
|
var d = deltaDeg % 360.0
|
||||||
|
if (d > 180.0) d -= 360.0
|
||||||
|
if (d < -180.0) d += 360.0
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert azimuth (deg, 0=N, clockwise) and elevation (deg) to canvas offset. */
|
||||||
|
private fun sph2Cart(center: Offset, azimDeg: Double, elevDeg: Double, r: Float): Offset {
|
||||||
|
val azimRad = azimDeg * PI / 180.0
|
||||||
|
val elevRad = elevDeg * PI / 180.0
|
||||||
|
val radius = r * (PI / 2 - elevRad) / (PI / 2)
|
||||||
|
return Offset(
|
||||||
|
x = center.x + (radius * cos(PI / 2 - azimRad)).toFloat(),
|
||||||
|
y = center.y - (radius * sin(PI / 2 - azimRad)).toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
||||||
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MutualScreen(
|
||||||
|
viewModel: MutualViewModel,
|
||||||
|
navigateUp: () -> Unit = {},
|
||||||
|
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
ScreenColumn(
|
||||||
|
topBar = { isVertical ->
|
||||||
|
TopBar(
|
||||||
|
isVerticalLayout = isVertical,
|
||||||
|
startAction = {
|
||||||
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
|
},
|
||||||
|
topInfo = {
|
||||||
|
Text(
|
||||||
|
text = "过境匹配",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomInfo = {
|
||||||
|
if (state.mutualPasses.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "找到 ${state.mutualPasses.size} 个匹配",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
endAction = {
|
||||||
|
if (state.isCalculating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.width(24.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { isVertical ->
|
||||||
|
MutualContent(
|
||||||
|
state = state,
|
||||||
|
isVertical = isVertical,
|
||||||
|
onQuery = viewModel::queryMutualPasses,
|
||||||
|
onSelectPass = viewModel::onSelectPass,
|
||||||
|
onNavigateToRadar = navigateToRadar,
|
||||||
|
onStationALat = viewModel::onStationALat,
|
||||||
|
onStationALon = viewModel::onStationALon,
|
||||||
|
onStationAGrid = viewModel::onStationAGrid,
|
||||||
|
onStationAMinElev = viewModel::onStationAMinElev,
|
||||||
|
onStationBLat = viewModel::onStationBLat,
|
||||||
|
onStationBLon = viewModel::onStationBLon,
|
||||||
|
onStationBGrid = viewModel::onStationBGrid,
|
||||||
|
onStationBMinElev = viewModel::onStationBMinElev,
|
||||||
|
onUseCurrentPosition = viewModel::onUseCurrentPosition,
|
||||||
|
onHoursAhead = viewModel::onHoursAhead,
|
||||||
|
onClearError = viewModel::clearError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MutualContent(
|
||||||
|
state: MutualUiState,
|
||||||
|
isVertical: Boolean,
|
||||||
|
onQuery: () -> Unit,
|
||||||
|
onSelectPass: (Int) -> Unit,
|
||||||
|
onNavigateToRadar: (Int, Long, MutualPassData?) -> Unit,
|
||||||
|
onStationALat: (String) -> Unit,
|
||||||
|
onStationALon: (String) -> Unit,
|
||||||
|
onStationAGrid: (String) -> Unit,
|
||||||
|
onStationAMinElev: (Double) -> Unit,
|
||||||
|
onStationBLat: (String) -> Unit,
|
||||||
|
onStationBLon: (String) -> Unit,
|
||||||
|
onStationBGrid: (String) -> Unit,
|
||||||
|
onStationBMinElev: (Double) -> Unit,
|
||||||
|
onUseCurrentPosition: () -> Unit,
|
||||||
|
onHoursAhead: (Int) -> Unit,
|
||||||
|
onClearError: () -> Unit
|
||||||
|
) {
|
||||||
|
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// Error message
|
||||||
|
val errorMsg = state.errorMessage
|
||||||
|
if (errorMsg != null) {
|
||||||
|
item {
|
||||||
|
ElevatedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.elevatedCardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorMsg,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input form
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
MatchIntroCard()
|
||||||
|
|
||||||
|
if (isVertical) {
|
||||||
|
StationInputCard(
|
||||||
|
title = "你的位置",
|
||||||
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
|
lat = state.stationALat,
|
||||||
|
lon = state.stationALon,
|
||||||
|
grid = state.stationAGrid,
|
||||||
|
minElev = state.stationAMinElev,
|
||||||
|
latPlaceholder = "39.9042",
|
||||||
|
lonPlaceholder = "116.4074",
|
||||||
|
gridPlaceholder = "ON79uj",
|
||||||
|
onLatChange = onStationALat,
|
||||||
|
onLonChange = onStationALon,
|
||||||
|
onGridChange = onStationAGrid,
|
||||||
|
onMinElevChange = onStationAMinElev,
|
||||||
|
currentPositionAction = onUseCurrentPosition
|
||||||
|
)
|
||||||
|
StationInputCard(
|
||||||
|
title = "友台位置",
|
||||||
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
|
lat = state.stationBLat,
|
||||||
|
lon = state.stationBLon,
|
||||||
|
grid = state.stationBGrid,
|
||||||
|
minElev = state.stationBMinElev,
|
||||||
|
latPlaceholder = "34.0522",
|
||||||
|
lonPlaceholder = "-118.2437",
|
||||||
|
gridPlaceholder = "PM01tv",
|
||||||
|
onLatChange = onStationBLat,
|
||||||
|
onLonChange = onStationBLon,
|
||||||
|
onGridChange = onStationBGrid,
|
||||||
|
onMinElevChange = onStationBMinElev
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
StationInputCard(
|
||||||
|
title = "你的位置",
|
||||||
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
|
lat = state.stationALat,
|
||||||
|
lon = state.stationALon,
|
||||||
|
grid = state.stationAGrid,
|
||||||
|
minElev = state.stationAMinElev,
|
||||||
|
latPlaceholder = "39.9042",
|
||||||
|
lonPlaceholder = "116.4074",
|
||||||
|
gridPlaceholder = "ON79uj",
|
||||||
|
onLatChange = onStationALat,
|
||||||
|
onLonChange = onStationALon,
|
||||||
|
onGridChange = onStationAGrid,
|
||||||
|
onMinElevChange = onStationAMinElev,
|
||||||
|
currentPositionAction = onUseCurrentPosition,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StationInputCard(
|
||||||
|
title = "友台位置",
|
||||||
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
|
lat = state.stationBLat,
|
||||||
|
lon = state.stationBLon,
|
||||||
|
grid = state.stationBGrid,
|
||||||
|
minElev = state.stationBMinElev,
|
||||||
|
latPlaceholder = "34.0522",
|
||||||
|
lonPlaceholder = "-118.2437",
|
||||||
|
gridPlaceholder = "PM01tv",
|
||||||
|
onLatChange = onStationBLat,
|
||||||
|
onLonChange = onStationBLon,
|
||||||
|
onGridChange = onStationBGrid,
|
||||||
|
onMinElevChange = onStationBMinElev,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchSearchCard(
|
||||||
|
hoursAhead = state.hoursAhead,
|
||||||
|
isCalculating = state.isCalculating,
|
||||||
|
onHoursAhead = onHoursAhead,
|
||||||
|
onQuery = onQuery
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results
|
||||||
|
itemsIndexed(state.mutualPasses) { index, pass ->
|
||||||
|
val mutualData = MutualPassData(
|
||||||
|
samples = pass.elevationSamples,
|
||||||
|
trackSamples = pass.trackSamples.map {
|
||||||
|
TrackSampleData(
|
||||||
|
time = it.time,
|
||||||
|
azimuthA = it.azimuthA,
|
||||||
|
elevationA = it.elevationA,
|
||||||
|
azimuthB = it.azimuthB,
|
||||||
|
elevationB = it.elevationB
|
||||||
|
)
|
||||||
|
},
|
||||||
|
startTime = pass.startTime,
|
||||||
|
endTime = pass.endTime,
|
||||||
|
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
|
||||||
|
labelA = "你",
|
||||||
|
labelB = "友台"
|
||||||
|
)
|
||||||
|
MutualPassCard(
|
||||||
|
pass = pass,
|
||||||
|
isExpanded = state.selectedPassIndex == index,
|
||||||
|
timeFormat = timeFormat,
|
||||||
|
minElevA = state.stationAMinElev,
|
||||||
|
minElevB = state.stationBMinElev,
|
||||||
|
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
|
||||||
|
onNavigateToRadar = { onNavigateToRadar(pass.catNum, pass.startTime, mutualData) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MatchIntroCard(modifier: Modifier = Modifier) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "位置匹配",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "输入双方位置,查找共同可见的卫星过境窗口",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StationInputCard(
|
||||||
|
title: String,
|
||||||
|
titleColor: androidx.compose.ui.graphics.Color,
|
||||||
|
lat: String,
|
||||||
|
lon: String,
|
||||||
|
grid: String,
|
||||||
|
minElev: Double,
|
||||||
|
latPlaceholder: String,
|
||||||
|
lonPlaceholder: String,
|
||||||
|
gridPlaceholder: String,
|
||||||
|
onLatChange: (String) -> Unit,
|
||||||
|
onLonChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onMinElevChange: (Double) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
currentPositionAction: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = titleColor
|
||||||
|
)
|
||||||
|
if (currentPositionAction != null) {
|
||||||
|
OutlinedButton(onClick = currentPositionAction) {
|
||||||
|
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lat,
|
||||||
|
onValueChange = onLatChange,
|
||||||
|
label = { Text("纬度") },
|
||||||
|
placeholder = { Text(latPlaceholder) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lon,
|
||||||
|
onValueChange = onLonChange,
|
||||||
|
label = { Text("经度") },
|
||||||
|
placeholder = { Text(lonPlaceholder) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = grid,
|
||||||
|
onValueChange = onGridChange,
|
||||||
|
label = { Text("网格") },
|
||||||
|
placeholder = { Text(gridPlaceholder) },
|
||||||
|
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "最小仰角:${minElev.toInt()}°",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = minElev.toFloat(),
|
||||||
|
onValueChange = { onMinElevChange(it.toDouble()) },
|
||||||
|
valueRange = 0f..90f,
|
||||||
|
steps = 17
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MatchSearchCard(
|
||||||
|
hoursAhead: Int,
|
||||||
|
isCalculating: Boolean,
|
||||||
|
onHoursAhead: (Int) -> Unit,
|
||||||
|
onQuery: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "时间范围",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
listOf(6, 12, 24, 48, 72).forEach { hours ->
|
||||||
|
FilterChip(
|
||||||
|
selected = hoursAhead == hours,
|
||||||
|
onClick = { onHoursAhead(hours) },
|
||||||
|
label = { Text("${hours}h") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onQuery,
|
||||||
|
enabled = !isCalculating,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isCalculating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(18.dp)
|
||||||
|
.width(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(if (isCalculating) "计算中..." else "查询匹配")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MutualPassCard(
|
||||||
|
pass: MutualPass,
|
||||||
|
isExpanded: Boolean,
|
||||||
|
timeFormat: SimpleDateFormat,
|
||||||
|
minElevA: Double,
|
||||||
|
minElevB: Double,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onNavigateToRadar: () -> Unit
|
||||||
|
) {
|
||||||
|
// Shared time cursor: both the elevation curve and the radar track view
|
||||||
|
// are controlled by this single progress value for bidirectional drag sync.
|
||||||
|
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
|
||||||
|
|
||||||
|
// Filter to only show the portion where both stations are above their minElev
|
||||||
|
val visibleSamples = remember(pass, minElevA, minElevB) {
|
||||||
|
pass.elevationSamples.filter { (_, elev) ->
|
||||||
|
elev.first >= minElevA && elev.second >= minElevB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val visibleTracks = remember(pass, minElevA, minElevB) {
|
||||||
|
pass.trackSamples.filter { it.elevationA >= minElevA && it.elevationB >= minElevB }
|
||||||
|
}
|
||||||
|
val visibleStart = visibleSamples.firstOrNull()?.first ?: pass.startTime
|
||||||
|
val visibleEnd = visibleSamples.lastOrNull()?.first ?: pass.endTime
|
||||||
|
val adjustedMaxElev = maxOf(
|
||||||
|
visibleSamples.maxOfOrNull { (_, elev) -> elev.first } ?: 10.0,
|
||||||
|
visibleSamples.maxOfOrNull { (_, elev) -> elev.second } ?: 10.0,
|
||||||
|
10.0
|
||||||
|
)
|
||||||
|
ElevatedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = pass.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "你: ${pass.maxElevationA}°",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "友台: ${pass.maxElevationB}°",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically()
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
// Dual-station radar track (polar plot)
|
||||||
|
if (visibleTracks.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "双方轨迹",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
MutualRadarView(
|
||||||
|
trackSamples = visibleTracks,
|
||||||
|
progress = dragProgress,
|
||||||
|
labelA = "你",
|
||||||
|
labelB = "友台"
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
if (visibleSamples.isNotEmpty()) {
|
||||||
|
ElevationCurveChart(
|
||||||
|
samples = visibleSamples,
|
||||||
|
startTime = visibleStart,
|
||||||
|
endTime = visibleEnd,
|
||||||
|
maxElev = adjustedMaxElev,
|
||||||
|
progress = dragProgress,
|
||||||
|
onProgressChange = { dragProgress = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onNavigateToRadar,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_radar),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.height(16.dp).width(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("查看雷达")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
data class MutualUiState(
|
||||||
|
val stationALat: String = "",
|
||||||
|
val stationALon: String = "",
|
||||||
|
val stationAGrid: String = "",
|
||||||
|
val stationAMinElev: Double = 10.0,
|
||||||
|
val stationBLat: String = "",
|
||||||
|
val stationBLon: String = "",
|
||||||
|
val stationBGrid: String = "",
|
||||||
|
val stationBMinElev: Double = 10.0,
|
||||||
|
val hoursAhead: Int = 24,
|
||||||
|
val mutualPasses: List<MutualPass> = emptyList(),
|
||||||
|
val isCalculating: Boolean = false,
|
||||||
|
val selectedPassIndex: Int = -1,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class MutualViewModel(
|
||||||
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(MutualUiState())
|
||||||
|
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Pre-fill station A with the user's current station position (as grid),
|
||||||
|
// and default min elevation to the same value used by the main radar passes
|
||||||
|
val pos = settingsRepo.stationPosition.value
|
||||||
|
val grid = latLonToGrid(pos.latitude, pos.longitude)
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationAGrid = grid,
|
||||||
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
|
stationALon = "%.4f".format(pos.longitude),
|
||||||
|
stationAMinElev = 0.0,
|
||||||
|
stationBMinElev = 0.0
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationALat(value: String) {
|
||||||
|
_uiState.update { it.copy(stationALat = value) }
|
||||||
|
val lat = value.toDoubleOrNull()
|
||||||
|
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationALon(value: String) {
|
||||||
|
_uiState.update { it.copy(stationALon = value) }
|
||||||
|
val lat = _uiState.value.stationALat.toDoubleOrNull()
|
||||||
|
val lon = value.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationAGrid(value: String) {
|
||||||
|
val old = _uiState.value.stationAGrid
|
||||||
|
_uiState.update { it.copy(stationAGrid = value) }
|
||||||
|
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||||
|
val pos = gridToLatLon(value.trim().uppercase())
|
||||||
|
if (pos != null) {
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
|
stationALon = "%.4f".format(pos.longitude)
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationBLat(value: String) {
|
||||||
|
_uiState.update { it.copy(stationBLat = value) }
|
||||||
|
val lat = value.toDoubleOrNull()
|
||||||
|
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationBLon(value: String) {
|
||||||
|
_uiState.update { it.copy(stationBLon = value) }
|
||||||
|
val lat = _uiState.value.stationBLat.toDoubleOrNull()
|
||||||
|
val lon = value.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationBGrid(value: String) {
|
||||||
|
val old = _uiState.value.stationBGrid
|
||||||
|
_uiState.update { it.copy(stationBGrid = value) }
|
||||||
|
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||||
|
val pos = gridToLatLon(value.trim().uppercase())
|
||||||
|
if (pos != null) {
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationBLat = "%.4f".format(pos.latitude),
|
||||||
|
stationBLon = "%.4f".format(pos.longitude)
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onStationAMinElev(value: Double) = _uiState.update { it.copy(stationAMinElev = value) }
|
||||||
|
fun onStationBMinElev(value: Double) = _uiState.update { it.copy(stationBMinElev = value) }
|
||||||
|
fun onUseCurrentPosition() {
|
||||||
|
val pos = settingsRepo.stationPosition.value
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
|
stationALon = "%.4f".format(pos.longitude),
|
||||||
|
stationAGrid = latLonToGrid(pos.latitude, pos.longitude)
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||||
|
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||||
|
|
||||||
|
fun queryMutualPasses() {
|
||||||
|
val state = _uiState.value
|
||||||
|
|
||||||
|
// Resolve positions from lat/lon or grid
|
||||||
|
var posA = resolvePosition(state.stationALat, state.stationALon, state.stationAGrid)
|
||||||
|
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
|
||||||
|
|
||||||
|
if (posA == null || posB == null) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标或网格(4/6/8位)") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val satellites = satelliteRepo.satellites.value
|
||||||
|
if (satellites.isEmpty()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val time = System.currentTimeMillis()
|
||||||
|
val minElevA = state.stationAMinElev
|
||||||
|
val minElevB = state.stationBMinElev
|
||||||
|
val hours = state.hoursAhead
|
||||||
|
|
||||||
|
val results = withContext(Dispatchers.Default) {
|
||||||
|
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorMsg = if (results.isEmpty()) {
|
||||||
|
// Debug: check if the main pass list is the culprit
|
||||||
|
val passCount = satelliteRepo.passes.value.size
|
||||||
|
val satCount = satellites.size
|
||||||
|
when {
|
||||||
|
passCount == 0 -> "过境列表为空,请先在卫星列表中选择卫星"
|
||||||
|
satCount == 0 -> "没有已选中的卫星"
|
||||||
|
else -> "未找到共同过境(${passCount}个过境,${satCount}颗卫星)"
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
mutualPasses = results.sortedBy { mp -> mp.startTime },
|
||||||
|
isCalculating = false,
|
||||||
|
errorMessage = errorMsg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a position from lat/lon text or grid square text. */
|
||||||
|
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||||
|
val trimmed = gridText.trim().uppercase()
|
||||||
|
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||||
|
return gridToLatLon(trimmed)
|
||||||
|
}
|
||||||
|
val lat = latText.toDoubleOrNull()
|
||||||
|
val lon = lonText.toDoubleOrNull()
|
||||||
|
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert lat/lon to Maidenhead grid (6-char). */
|
||||||
|
private fun latLonToGrid(lat: Double, lon: Double): String {
|
||||||
|
var adjLon = (lon + 180.0) % 360.0
|
||||||
|
var adjLat = (lat + 90.0) % 180.0
|
||||||
|
|
||||||
|
val fieldLon = (adjLon / 20.0).toInt()
|
||||||
|
val fieldLat = (adjLat / 10.0).toInt()
|
||||||
|
adjLon -= fieldLon * 20.0
|
||||||
|
adjLat -= fieldLat * 10.0
|
||||||
|
|
||||||
|
val squareLon = (adjLon / 2.0).toInt()
|
||||||
|
val squareLat = (adjLat / 1.0).toInt()
|
||||||
|
adjLon -= squareLon * 2.0
|
||||||
|
adjLat -= squareLat * 1.0
|
||||||
|
|
||||||
|
val subLon = (adjLon * 60.0 / 5.0).toInt()
|
||||||
|
val subLat = (adjLat * 60.0 / 2.5).toInt()
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
append('A' + fieldLon)
|
||||||
|
append('A' + fieldLat)
|
||||||
|
append('0' + squareLon)
|
||||||
|
append('0' + squareLat)
|
||||||
|
append('A' + subLon)
|
||||||
|
append('A' + subLat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
|
||||||
|
private fun gridToLatLon(grid: String): GeoPos? {
|
||||||
|
val g = grid.uppercase()
|
||||||
|
if (g.length < 4) return null
|
||||||
|
val lonField = (g[0] - 'A').toDouble() * 20.0
|
||||||
|
val latField = (g[1] - 'A').toDouble() * 10.0
|
||||||
|
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
|
||||||
|
|
||||||
|
val lonSquare = (g[2] - '0').toDouble() * 2.0
|
||||||
|
val latSquare = (g[3] - '0').toDouble() * 1.0
|
||||||
|
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
|
||||||
|
|
||||||
|
var lon = lonField + lonSquare
|
||||||
|
var lat = latField + latSquare
|
||||||
|
|
||||||
|
if (g.length >= 6) {
|
||||||
|
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
|
||||||
|
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
|
||||||
|
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
|
||||||
|
lon += lonSub
|
||||||
|
lat += latSub
|
||||||
|
|
||||||
|
if (g.length >= 8) {
|
||||||
|
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
|
||||||
|
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
|
||||||
|
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
|
||||||
|
lon += lonExt + 15.0 / 3600.0
|
||||||
|
lat += latExt + 7.5 / 3600.0
|
||||||
|
} else {
|
||||||
|
lon += 2.5 / 60.0
|
||||||
|
lat += 1.25 / 60.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lon += 1.0
|
||||||
|
lat += 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
lon = lon - 180.0
|
||||||
|
lat = lat - 90.0
|
||||||
|
return GeoPos(lat, lon)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findMutualPasses(
|
||||||
|
satellites: List<OrbitalObject>,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
time: Long, hours: Int
|
||||||
|
): List<MutualPass> {
|
||||||
|
val endTime = time + hours * 60L * 60L * 1000L
|
||||||
|
val sampleInterval = 5_000L
|
||||||
|
|
||||||
|
// Use the main page's pass list for AOS/LOS times, then sample the curves
|
||||||
|
// using the actual positions. The passes list is already computed by getLeoPass
|
||||||
|
// and its AOS/LOS times match the Passes page exactly.
|
||||||
|
val existingPasses = satelliteRepo.passes.value
|
||||||
|
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
|
||||||
|
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||||
|
if (results.isNotEmpty()) return results
|
||||||
|
|
||||||
|
// Fallback: try the independent search.
|
||||||
|
return findMutualPassesFallback(satellites, posA, posB,
|
||||||
|
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reuse the main page's pass list. Uses AOS/LOS times directly (no refineEdge) */
|
||||||
|
private fun findMutualPassesFromList(
|
||||||
|
existingPasses: List<OrbitalPass>,
|
||||||
|
satellites: List<OrbitalObject>,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
time: Long, endTime: Long, sampleInterval: Long
|
||||||
|
): List<MutualPass> {
|
||||||
|
val results = mutableListOf<MutualPass>()
|
||||||
|
for (pass in existingPasses) {
|
||||||
|
if (pass.losTime <= time || pass.aosTime >= endTime) continue
|
||||||
|
if (pass.isDeepSpace) continue
|
||||||
|
if (pass.orbitalObject.data.meanmo < 1e-8) continue
|
||||||
|
|
||||||
|
val sat = pass.orbitalObject
|
||||||
|
// Refine the AOS/LOS to the actual posA/posB (0° horizon).
|
||||||
|
// This ensures the elevation curve starts from ~0°.
|
||||||
|
val refinedAos = refineEdge(sat, posA, posB, pass.aosTime, 1_000L, goingUp = true)
|
||||||
|
val refinedLos = refineEdge(sat, posA, posB, pass.losTime, 1_000L, goingUp = false)
|
||||||
|
if (refinedLos <= refinedAos) continue
|
||||||
|
|
||||||
|
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
|
||||||
|
val tracks = mutableListOf<TrackSample>()
|
||||||
|
var maxElevA = 0.0
|
||||||
|
var maxElevB = 0.0
|
||||||
|
|
||||||
|
var tSample = refinedAos
|
||||||
|
while (tSample <= refinedLos) {
|
||||||
|
// Use getElevation (same function used by getLeoPass) for elevation,
|
||||||
|
// and getFullPosition only for azimuth.
|
||||||
|
val elevA = sat.getElevation(posA, tSample) * 180.0 / PI
|
||||||
|
val elevB = sat.getElevation(posB, tSample) * 180.0 / PI
|
||||||
|
val fullA = sat.getFullPosition(posA, tSample)
|
||||||
|
val fullB = sat.getFullPosition(posB, tSample)
|
||||||
|
if (elevA > maxElevA) maxElevA = elevA
|
||||||
|
if (elevB > maxElevB) maxElevB = elevB
|
||||||
|
samples.add(tSample to (elevA to elevB))
|
||||||
|
tracks.add(
|
||||||
|
TrackSample(
|
||||||
|
time = tSample,
|
||||||
|
azimuthA = fullA.azimuth * 180.0 / PI,
|
||||||
|
elevationA = elevA,
|
||||||
|
azimuthB = fullB.azimuth * 180.0 / PI,
|
||||||
|
elevationB = elevB
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tSample += sampleInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search uses the 0° horizon as the pass boundary, but the user sliders are
|
||||||
|
// a final visibility filter. Both stations must exceed their own threshold;
|
||||||
|
// otherwise the result would expand to an empty filtered chart.
|
||||||
|
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
|
||||||
|
results.add(
|
||||||
|
MutualPass(
|
||||||
|
catNum = pass.catNum,
|
||||||
|
name = pass.orbitalObject.data.name,
|
||||||
|
startTime = refinedAos,
|
||||||
|
endTime = refinedLos,
|
||||||
|
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||||
|
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||||
|
elevationSamples = samples,
|
||||||
|
trackSamples = tracks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback: search passes independently (same logic as original findMutualPasses). */
|
||||||
|
private fun findMutualPassesFallback(
|
||||||
|
satellites: List<OrbitalObject>,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
time: Long, endTime: Long, sampleInterval: Long
|
||||||
|
): List<MutualPass> {
|
||||||
|
val results = mutableListOf<MutualPass>()
|
||||||
|
for (sat in satellites) {
|
||||||
|
if (sat.data.meanmo < 1e-8) continue
|
||||||
|
var searchStart = time
|
||||||
|
while (true) {
|
||||||
|
val t = findNextMutualPass(sat, posA, posB, searchStart, endTime)
|
||||||
|
if (t == null) break
|
||||||
|
val (aos, los) = t
|
||||||
|
|
||||||
|
val refinedAos = refineEdge(sat, posA, posB, aos, 1_000L, true)
|
||||||
|
val refinedLos = refineEdge(sat, posA, posB, los, 1_000L, false)
|
||||||
|
if (refinedLos <= refinedAos) continue
|
||||||
|
|
||||||
|
val mutualPass = sampleMutualPass(sat, posA, posB, refinedAos, refinedLos,
|
||||||
|
minElevADeg, minElevBDeg, sampleInterval)
|
||||||
|
if (mutualPass != null) results.add(mutualPass)
|
||||||
|
searchStart = refinedLos + 120_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample elevation/azimuth data for one mutual pass window. */
|
||||||
|
private fun sampleMutualPass(
|
||||||
|
sat: OrbitalObject, posA: GeoPos, posB: GeoPos,
|
||||||
|
refinedAos: Long, refinedLos: Long,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
sampleInterval: Long
|
||||||
|
): MutualPass? {
|
||||||
|
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
|
||||||
|
val tracks = mutableListOf<TrackSample>()
|
||||||
|
var maxElevA = 0.0
|
||||||
|
var maxElevB = 0.0
|
||||||
|
|
||||||
|
var tSample = refinedAos
|
||||||
|
while (tSample <= refinedLos) {
|
||||||
|
// Use getElevation for elevation, matching the main pass predictor; use
|
||||||
|
// getFullPosition only for azimuth needed by the radar plot.
|
||||||
|
val elevA = elevationDeg(sat, posA, tSample)
|
||||||
|
val elevB = elevationDeg(sat, posB, tSample)
|
||||||
|
val fullA = sat.getFullPosition(posA, tSample)
|
||||||
|
val fullB = sat.getFullPosition(posB, tSample)
|
||||||
|
if (elevA > maxElevA) maxElevA = elevA
|
||||||
|
if (elevB > maxElevB) maxElevB = elevB
|
||||||
|
samples.add(tSample to (elevA to elevB))
|
||||||
|
tracks.add(
|
||||||
|
TrackSample(
|
||||||
|
time = tSample,
|
||||||
|
azimuthA = fullA.azimuth * 180.0 / PI,
|
||||||
|
elevationA = elevA,
|
||||||
|
azimuthB = fullB.azimuth * 180.0 / PI,
|
||||||
|
elevationB = elevB
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tSample += sampleInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
|
||||||
|
return MutualPass(
|
||||||
|
catNum = sat.data.catnum,
|
||||||
|
name = sat.data.name,
|
||||||
|
startTime = refinedAos,
|
||||||
|
endTime = refinedLos,
|
||||||
|
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||||
|
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||||
|
elevationSamples = samples,
|
||||||
|
trackSamples = tracks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refine the AOS (goingUp=true) or LOS (goingUp=false) to ~1s precision at the 0° horizon. */
|
||||||
|
private fun refineEdge(
|
||||||
|
sat: OrbitalObject, posA: GeoPos, posB: GeoPos,
|
||||||
|
approxTime: Long, step: Long, goingUp: Boolean
|
||||||
|
): Long {
|
||||||
|
if (goingUp) {
|
||||||
|
// AOS: walk backward from approxTime to find the last sample where either is below
|
||||||
|
// the horizon, then AOS is the next step after that.
|
||||||
|
var t = approxTime
|
||||||
|
while (t > approxTime - 70_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, t)
|
||||||
|
val eB = elevationDeg(sat, posB, t)
|
||||||
|
if (eA > 0.0 && eB > 0.0) {
|
||||||
|
t -= step
|
||||||
|
} else {
|
||||||
|
return t + step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return approxTime - 70_000L + step
|
||||||
|
} else {
|
||||||
|
// LOS: walk forward from approxTime to find the first sample where either drops
|
||||||
|
// below the horizon, then LOS is the step before that.
|
||||||
|
var t = approxTime
|
||||||
|
while (t < approxTime + 70_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, t)
|
||||||
|
val eB = elevationDeg(sat, posB, t)
|
||||||
|
if (eA > 0.0 && eB > 0.0) {
|
||||||
|
t += step
|
||||||
|
} else {
|
||||||
|
return t - step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return approxTime + 70_000L - step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun elevationDeg(sat: OrbitalObject, pos: GeoPos, time: Long): Double {
|
||||||
|
return sat.getElevation(pos, time) * 180.0 / PI
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findNextMutualPass(
|
||||||
|
sat: OrbitalObject,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
startTime: Long, endTime: Long
|
||||||
|
): Pair<Long, Long>? {
|
||||||
|
var t = startTime
|
||||||
|
val step = 60_000L
|
||||||
|
|
||||||
|
// Skip an in-progress mutual window at the search start (same as getLeoPass):
|
||||||
|
// walk forward until either station drops below the horizon, then keep searching.
|
||||||
|
if (elevationDeg(sat, posA, t) > 0.0 && elevationDeg(sat, posB, t) > 0.0) {
|
||||||
|
while (t < endTime) {
|
||||||
|
if (elevationDeg(sat, posA, t) <= 0.0 || elevationDeg(sat, posB, t) <= 0.0) break
|
||||||
|
t += step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (t < endTime) {
|
||||||
|
val elevA = elevationDeg(sat, posA, t)
|
||||||
|
val elevB = elevationDeg(sat, posB, t)
|
||||||
|
|
||||||
|
if (elevA > 0.0 && elevB > 0.0) {
|
||||||
|
var aos = t
|
||||||
|
var rew = t
|
||||||
|
while (rew > startTime - 600_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, rew)
|
||||||
|
val eB = elevationDeg(sat, posB, rew)
|
||||||
|
if (eA <= 0.0 || eB <= 0.0) {
|
||||||
|
aos = rew + step
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rew -= step
|
||||||
|
}
|
||||||
|
|
||||||
|
var los = t
|
||||||
|
var fwd = t
|
||||||
|
while (fwd < endTime + 600_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, fwd)
|
||||||
|
val eB = elevationDeg(sat, posB, fwd)
|
||||||
|
if (eA <= 0.0 || eB <= 0.0) {
|
||||||
|
los = fwd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fwd += step
|
||||||
|
}
|
||||||
|
|
||||||
|
if (los > aos) return Pair(aos, los)
|
||||||
|
}
|
||||||
|
t += step
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() = _uiState.update { it.copy(errorMessage = null) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(container: IMainContainer) = object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
MutualViewModel(
|
||||||
|
satelliteRepo = container.satelliteRepo,
|
||||||
|
settingsRepo = container.settingsRepo
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,9 +62,16 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog
|
|||||||
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
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
|
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 dayMinutes = 24 * 60
|
||||||
private const val minuteStep = 15
|
private const val minuteStep = 15
|
||||||
@@ -374,7 +381,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
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -148,7 +147,6 @@ private fun PassesScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(horizontal = padding)
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
@@ -156,7 +154,7 @@ private fun PassesScreen(
|
|||||||
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)
|
||||||
@@ -165,7 +163,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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -292,11 +290,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() {
|
||||||
@@ -323,13 +316,12 @@ private fun PassItem(
|
|||||||
isVerticalLayout: Boolean = true,
|
isVerticalLayout: Boolean = true,
|
||||||
isUtc: Boolean = false
|
isUtc: Boolean = false
|
||||||
) {
|
) {
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
|
||||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
||||||
val timeZone = remember(isUtc) {
|
val timeZone = remember(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.getDefault()).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)) }
|
||||||
@@ -349,10 +341,6 @@ private fun PassItem(
|
|||||||
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
|
||||||
text = "$passSatId - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = pass.name,
|
text = pass.name,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -362,7 +350,7 @@ private fun PassItem(
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
val elevColor = elevationColor(pass.maxElevation)
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
+49
-69
@@ -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,
|
||||||
@@ -64,7 +60,7 @@ class PassesViewModel(
|
|||||||
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
||||||
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -90,42 +86,32 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +127,21 @@ class PassesViewModel(
|
|||||||
aosStartMinute = action.aosStartMinute,
|
aosStartMinute = action.aosStartMinute,
|
||||||
aosEndMinute = action.aosEndMinute,
|
aosEndMinute = action.aosEndMinute,
|
||||||
invertAosTimeWindow = action.invertAosTimeWindow,
|
invertAosTimeWindow = action.invertAosTimeWindow,
|
||||||
showDeepSpace = action.showDeepSpace
|
showDeepSpace = action.showDeepSpace,
|
||||||
|
modes = _uiState.value.modes
|
||||||
|
)
|
||||||
|
is PassesAction.FilterRadios ->
|
||||||
|
applyFilter(
|
||||||
|
hoursAhead = _uiState.value.hours,
|
||||||
|
minElevation = _uiState.value.elevation,
|
||||||
|
lowElevation = _uiState.value.lowElevation,
|
||||||
|
highElevation = _uiState.value.highElevation,
|
||||||
|
aosStartMinute = _uiState.value.aosStartMinute,
|
||||||
|
aosEndMinute = _uiState.value.aosEndMinute,
|
||||||
|
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
||||||
|
showDeepSpace = _uiState.value.showDeepSpace,
|
||||||
|
modes = action.modes
|
||||||
)
|
)
|
||||||
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,20 +152,14 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun displayLocale(): Locale {
|
/** Returns a locale-appropriate date format for pass grouping headers. */
|
||||||
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 {
|
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
|
||||||
val locale = displayLocale()
|
val pattern = if (Locale.getDefault().language == "zh") {
|
||||||
val pattern = if (locale.language == Locale.CHINESE.language) {
|
|
||||||
"yyyy'年'M'月'd'日' EEEE"
|
"yyyy'年'M'月'd'日' EEEE"
|
||||||
} else {
|
} else {
|
||||||
"EEE, dd MMM yyyy"
|
"EEE, dd MMM yyyy"
|
||||||
}
|
}
|
||||||
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
|
return SimpleDateFormat(pattern, Locale.getDefault()).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
|
||||||
@@ -175,7 +167,7 @@ class PassesViewModel(
|
|||||||
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 = dateFormat(tz)
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).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 }) {
|
||||||
@@ -253,7 +245,8 @@ class PassesViewModel(
|
|||||||
aosStartMinute: Int,
|
aosStartMinute: Int,
|
||||||
aosEndMinute: Int,
|
aosEndMinute: Int,
|
||||||
invertAosTimeWindow: Boolean,
|
invertAosTimeWindow: Boolean,
|
||||||
showDeepSpace: Boolean
|
showDeepSpace: Boolean,
|
||||||
|
modes: List<String>
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
settingsRepo.setPassesSettings(
|
settingsRepo.setPassesSettings(
|
||||||
PassesSettings(
|
PassesSettings(
|
||||||
@@ -262,7 +255,8 @@ class PassesViewModel(
|
|||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow
|
invertAosTimeWindow,
|
||||||
|
modes
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
||||||
@@ -275,10 +269,10 @@ class PassesViewModel(
|
|||||||
aosStartMinute = aosStartMinute,
|
aosStartMinute = aosStartMinute,
|
||||||
aosEndMinute = aosEndMinute,
|
aosEndMinute = aosEndMinute,
|
||||||
invertAosTimeWindow = invertAosTimeWindow,
|
invertAosTimeWindow = invertAosTimeWindow,
|
||||||
showDeepSpace = showDeepSpace
|
showDeepSpace = showDeepSpace,
|
||||||
|
modes = modes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val modes = settingsRepo.selectedSatModes.value
|
|
||||||
satelliteRepo.calculatePasses(
|
satelliteRepo.calculatePasses(
|
||||||
time = System.currentTimeMillis(),
|
time = System.currentTimeMillis(),
|
||||||
hoursAhead = hoursAhead,
|
hoursAhead = hoursAhead,
|
||||||
@@ -290,20 +284,6 @@ class PassesViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
|
|
||||||
settingsRepo.setSelectedSatModes(modes)
|
|
||||||
_uiState.update { it.copy(modes = modes) }
|
|
||||||
satelliteRepo.calculatePasses(
|
|
||||||
time = System.currentTimeMillis(),
|
|
||||||
hoursAhead = _uiState.value.hours,
|
|
||||||
minElevation = _uiState.value.elevation,
|
|
||||||
aosStartMinute = _uiState.value.aosStartMinute,
|
|
||||||
aosEndMinute = _uiState.value.aosEndMinute,
|
|
||||||
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
|
||||||
modes = modes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshPasses() = viewModelScope.launch {
|
private fun refreshPasses() = viewModelScope.launch {
|
||||||
val settings = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
satelliteRepo.calculatePasses(
|
satelliteRepo.calculatePasses(
|
||||||
@@ -313,7 +293,7 @@ class PassesViewModel(
|
|||||||
aosStartMinute = settings.aosStartMinute,
|
aosStartMinute = settings.aosStartMinute,
|
||||||
aosEndMinute = settings.aosEndMinute,
|
aosEndMinute = settings.aosEndMinute,
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
modes = settingsRepo.selectedSatModes.value
|
modes = settings.selectedModes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.rtbishop.look4sat.feature.radar"
|
namespace = "com.rtbishop.look4sat.feature.radar"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":feature:mutual"))
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import 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.repository.MutualPassData
|
||||||
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
|
||||||
@@ -73,10 +73,11 @@ import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
|||||||
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 kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.PI
|
||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
Calculator("Calculator"),
|
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,17 +87,39 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
||||||
|
val navigateUpAndClearMutual = {
|
||||||
|
if (container.mutualPassData.value.endTime > 0L) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
}
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
LaunchedEffect(mutualData.endTime) {
|
||||||
|
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||||
|
while (true) {
|
||||||
|
val remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||||
|
if (remainingMs <= 0L) {
|
||||||
|
navigateUpAndClearMutual()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
delay(remainingMs.coerceAtMost(1000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sync actual permission state on every recomposition so it survives screen re-entry
|
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||||
val hasPermission = ContextCompat.checkSelfPermission(
|
val hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.RECORD_AUDIO
|
context, Manifest.permission.RECORD_AUDIO
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
LaunchedEffect(hasPermission) {
|
LaunchedEffect(hasPermission) {
|
||||||
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
|
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
|
||||||
|
viewModel.onAction(RadarAction.CwPermissionResult(hasPermission))
|
||||||
}
|
}
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
|
) { granted ->
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
|
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||||
|
}
|
||||||
|
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -106,12 +129,37 @@ private fun RadarScreen(
|
|||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
|
mutualData: MutualPassData,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||||
}
|
}
|
||||||
|
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
|
||||||
|
// at the current moment, same display mode as the local station.
|
||||||
|
val trackB = remember(mutualData.trackSamples) {
|
||||||
|
mutualData.trackSamples
|
||||||
|
.filter { it.elevationB > 0.0 }
|
||||||
|
.map {
|
||||||
|
OrbitalPos(
|
||||||
|
azimuth = it.azimuthB * PI / 180.0,
|
||||||
|
elevation = it.elevationB * PI / 180.0,
|
||||||
|
time = it.time
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val timeNow = System.currentTimeMillis()
|
||||||
|
val trackBPosition = mutualData.trackSamples
|
||||||
|
.filter { it.time <= timeNow }
|
||||||
|
.lastOrNull()
|
||||||
|
?.let {
|
||||||
|
OrbitalPos(
|
||||||
|
azimuth = it.azimuthB * PI / 180.0,
|
||||||
|
elevation = it.elevationB * PI / 180.0,
|
||||||
|
time = it.time
|
||||||
|
)
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutPadding()
|
.layoutPadding()
|
||||||
@@ -135,11 +183,11 @@ private fun RadarScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,31 +201,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) }
|
||||||
)
|
)
|
||||||
@@ -189,17 +222,14 @@ private fun PagerCard(
|
|||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
when (pages[pageIndex]) {
|
when (pages[pageIndex]) {
|
||||||
RadarPage.Transceivers -> TransceiversPage(
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
transceivers = uiState.transceivers.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
radioControl = uiState.radioControl,
|
orbitalPos = uiState.orbitalPos,
|
||||||
onAction = onAction
|
cw = uiState.cw,
|
||||||
)
|
radioControl = uiState.radioControl,
|
||||||
RadarPage.Calculator -> CalculatorPage(
|
onAction = onAction,
|
||||||
transceivers = uiState.transceivers.transmitters,
|
requestMicPermission = requestMicPermission
|
||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
)
|
||||||
orbitalPos = uiState.orbitalPos,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
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) },
|
||||||
@@ -213,7 +243,12 @@ private fun PagerCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
private fun RadarCard(
|
||||||
|
uiState: RadarState,
|
||||||
|
trackB: List<OrbitalPos> = emptyList(),
|
||||||
|
trackBPosition: OrbitalPos? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
val satellitePos = uiState.orbitalPos
|
val satellitePos = uiState.orbitalPos
|
||||||
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||||
// Always call these composables unconditionally — conditional composable calls violate
|
// Always call these composables unconditionally — conditional composable calls violate
|
||||||
@@ -246,10 +281,11 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
|||||||
RadarViewCompose(
|
RadarViewCompose(
|
||||||
item = position,
|
item = position,
|
||||||
items = uiState.satTrack,
|
items = uiState.satTrack,
|
||||||
|
trackB = trackB.takeIf { it.isNotEmpty() },
|
||||||
|
trackBPosition = trackBPosition,
|
||||||
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,
|
||||||
|
|||||||
@@ -58,12 +58,18 @@ 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 cw: CwSubState = CwSubState(),
|
||||||
|
val mutualSamples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
||||||
|
val mutualStartTime: Long = 0L,
|
||||||
|
val mutualEndTime: Long = 0L,
|
||||||
|
val mutualMaxElev: Double = 10.0,
|
||||||
|
val mutualLabelA: String = "你",
|
||||||
|
val mutualLabelB: String = "友台"
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class SstvStatus { Idle, Recording }
|
enum class SstvStatus { Idle, Recording }
|
||||||
@@ -78,6 +84,19 @@ data class SstvSubState(
|
|||||||
val diagnosticsMetrics: SstvQualityMetrics? = null
|
val diagnosticsMetrics: SstvQualityMetrics? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- CW Decoder ---
|
||||||
|
|
||||||
|
enum class CwStatus { Idle, Listening }
|
||||||
|
|
||||||
|
data class CwSubState(
|
||||||
|
val status: CwStatus = CwStatus.Idle,
|
||||||
|
val hasPermission: Boolean = false,
|
||||||
|
val decodedText: String = "",
|
||||||
|
val cwToneFreq: Float = 700f,
|
||||||
|
val isExpanded: Boolean = false,
|
||||||
|
val signalStrength: Float = 0f
|
||||||
|
)
|
||||||
|
|
||||||
sealed interface RadarAction {
|
sealed interface RadarAction {
|
||||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
||||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||||
@@ -97,4 +116,12 @@ 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
|
||||||
|
|
||||||
|
// CW actions
|
||||||
|
data object CwStartListening : RadarAction
|
||||||
|
data object CwStopListening : RadarAction
|
||||||
|
data object CwReset : RadarAction
|
||||||
|
data class CwSetToneFreq(val freq: Float) : RadarAction
|
||||||
|
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
||||||
|
data class CwPermissionResult(val granted: Boolean) : RadarAction
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.PI_2
|
import com.rtbishop.look4sat.core.domain.predict.PI_2
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
@@ -71,10 +73,12 @@ private const val SWEEP_DURATION_MS = 8_000
|
|||||||
fun RadarViewCompose(
|
fun RadarViewCompose(
|
||||||
item: OrbitalPos,
|
item: OrbitalPos,
|
||||||
items: List<OrbitalPos>,
|
items: List<OrbitalPos>,
|
||||||
|
trackB: List<OrbitalPos>? = null,
|
||||||
|
trackBColor: Color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
trackBPosition: OrbitalPos? = null,
|
||||||
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,
|
||||||
@@ -90,14 +94,21 @@ fun RadarViewCompose(
|
|||||||
animationSpec = infiniteRepeatable(tween(1000)),
|
animationSpec = infiniteRepeatable(tween(1000)),
|
||||||
label = "animScale"
|
label = "animScale"
|
||||||
)
|
)
|
||||||
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
|
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block.
|
||||||
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
// Only create the infinite transition when the sweep is enabled — otherwise the canvas would
|
||||||
val sweepDegrees by sweepTransition.animateFloat(
|
// be invalidated every frame for an effect that isn't visible, wasting GPU and battery.
|
||||||
initialValue = 0f,
|
val sweepDegrees = if (shouldShowSweep) {
|
||||||
targetValue = 360f,
|
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||||
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
val animatedSweep by sweepTransition.animateFloat(
|
||||||
label = "sweepDegrees"
|
initialValue = 0f,
|
||||||
)
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||||
|
label = "sweepDegrees"
|
||||||
|
)
|
||||||
|
animatedSweep
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
val sunPainter = painterResource(R.drawable.ic_sun)
|
val sunPainter = painterResource(R.drawable.ic_sun)
|
||||||
val moonPainter = painterResource(R.drawable.ic_moon)
|
val moonPainter = painterResource(R.drawable.ic_moon)
|
||||||
@@ -108,32 +119,53 @@ fun RadarViewCompose(
|
|||||||
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
||||||
var trackPath by remember { mutableStateOf(Path()) }
|
var trackPath by remember { mutableStateOf(Path()) }
|
||||||
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
||||||
|
// Station-B overlay track (dashed, with the same directional arrow stamp as station A)
|
||||||
|
var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
|
||||||
|
var trackBPath by remember { mutableStateOf(Path()) }
|
||||||
|
var trackBEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
||||||
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
||||||
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
||||||
|
|
||||||
Canvas(modifier = modifier.aspectRatio(1f)) {
|
Canvas(modifier = modifier.aspectRatio(1f)) {
|
||||||
val radius = size.minDimension / 2f * 0.95f
|
val radius = size.minDimension / 2f * 0.95f
|
||||||
// Rebuild track path and sweep brush when canvas size or track data changes
|
// Rebuild track path and sweep brush when canvas size or track data changes
|
||||||
if (radius != cachedRadius || items !== cachedItemsRef) {
|
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
|
||||||
trackPath = createTrackPath(items, radius)
|
trackPath = createTrackPath(items, radius)
|
||||||
trackEffect = createTrackEffect(trackPath)
|
trackEffect = createTrackEffect(trackPath)
|
||||||
|
val newTrackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
|
||||||
|
trackBPath = newTrackBPath
|
||||||
|
trackBEffect = if (!newTrackBPath.isEmpty) createTrackEffect(newTrackBPath)
|
||||||
|
else PathEffect.cornerPathEffect(0f)
|
||||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedRadius = radius
|
cachedRadius = radius
|
||||||
cachedItemsRef = items
|
cachedItemsRef = items
|
||||||
|
cachedTrackBRef = trackB
|
||||||
cachedSweepColor = primaryColor
|
cachedSweepColor = primaryColor
|
||||||
} else if (primaryColor != cachedSweepColor) {
|
} else if (primaryColor != cachedSweepColor) {
|
||||||
// Rebuild brush on theme change without waiting for a size change
|
// Rebuild brush on theme change without waiting for a size change
|
||||||
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)
|
||||||
translate(center.x, center.y) {
|
translate(center.x, center.y) {
|
||||||
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
|
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
|
||||||
|
// Station-B overlay: full dashed track + directional arrows + live position dot
|
||||||
|
// (same display mode as the local station: track line/arrow + pulsing dot)
|
||||||
|
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
|
||||||
|
drawPath(
|
||||||
|
trackBPath, trackBColor,
|
||||||
|
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
|
||||||
|
)
|
||||||
|
drawPath(trackBPath, trackBColor, style = Stroke(pathEffect = trackBEffect))
|
||||||
|
}
|
||||||
|
trackBPosition?.let { posB ->
|
||||||
|
if (posB.elevation > 0) {
|
||||||
|
drawPosition(posB, radius, animScale, trackBColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (item.elevation > 0) {
|
if (item.elevation > 0) {
|
||||||
drawPosition(item, radius, animScale, primaryColor)
|
drawPosition(item, radius, animScale, primaryColor)
|
||||||
}
|
}
|
||||||
@@ -208,13 +240,25 @@ private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, b
|
|||||||
|
|
||||||
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
|
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
|
||||||
val trackPath = Path()
|
val trackPath = Path()
|
||||||
|
var lastAzim: Double? = null
|
||||||
positions.forEachIndexed { index, pos ->
|
positions.forEachIndexed { index, pos ->
|
||||||
val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
|
val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
|
||||||
if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
|
// Split the path when azimuth wraps 0°/360° to avoid a line across the plot
|
||||||
|
val wrap = lastAzim != null && abs(azimuthDeltaRad(pos.azimuth - lastAzim!!)) > PI
|
||||||
|
if (index == 0 || wrap) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
|
||||||
|
lastAzim = pos.azimuth
|
||||||
}
|
}
|
||||||
return trackPath
|
return trackPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalize an azimuth delta (radians) into the [-PI, PI] range. */
|
||||||
|
private fun azimuthDeltaRad(deltaRad: Double): Double {
|
||||||
|
var d = deltaRad % (2 * PI)
|
||||||
|
if (d > PI) d -= 2 * PI
|
||||||
|
if (d < -PI) d += 2 * PI
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
private fun createTrackEffect(trackPath: Path): PathEffect {
|
private fun createTrackEffect(trackPath: Path): PathEffect {
|
||||||
val shapeRadius = 24f
|
val shapeRadius = 24f
|
||||||
val angle = 120.0.toRadians()
|
val angle = 120.0.toRadians()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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.LineRecoveryStrategy
|
||||||
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwDecoder
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
@@ -67,12 +68,11 @@ class RadarViewModel(
|
|||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
private val stationPos = settingsRepo.stationPosition.value
|
||||||
private val 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
|
private var cwDecoder: CwDecoder? = null
|
||||||
|
private var cwListeningJob: 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,49 +91,33 @@ class RadarViewModel(
|
|||||||
val uiState: StateFlow<RadarState> = _uiState
|
val uiState: StateFlow<RadarState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectSettingsChanges() // also handles initial sensor subscription
|
collectCompassSensor()
|
||||||
|
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +139,8 @@ class RadarViewModel(
|
|||||||
val passes = satelliteRepo.passes.value
|
val passes = satelliteRepo.passes.value
|
||||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||||
|
?: passes.find { it.catNum == catNum && aosTime in it.aosTime..it.losTime }
|
||||||
|
?: passes.find { it.catNum == catNum }
|
||||||
?: passes.firstOrNull()
|
?: passes.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +181,7 @@ class RadarViewModel(
|
|||||||
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
processRadios(allRadios, pass.orbitalObject, timeNow)
|
processRadios(allRadios, pos)
|
||||||
sendPassData(pos)
|
sendPassData(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +218,7 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
stopSensorCollection()
|
sensorsRepo.disableSensor()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
@@ -291,6 +277,25 @@ 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)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CW actions
|
||||||
|
is RadarAction.CwPermissionResult -> {
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(hasPermission = action.granted)) }
|
||||||
|
if (action.granted) initCwDecoder()
|
||||||
|
}
|
||||||
|
RadarAction.CwStartListening -> startCwListening()
|
||||||
|
RadarAction.CwStopListening -> stopCwListening()
|
||||||
|
RadarAction.CwReset -> {
|
||||||
|
cwDecoder?.resetDecoder()
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(decodedText = "")) }
|
||||||
|
}
|
||||||
|
is RadarAction.CwSetToneFreq -> {
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(cwToneFreq = action.freq)) }
|
||||||
|
cwDecoder = CwDecoder(sampleRate = audioCapture.sampleRate, cwToneFreq = action.freq)
|
||||||
|
}
|
||||||
|
is RadarAction.CwToggleExpanded -> {
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,8 +342,8 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
private suspend fun processRadios(radios: List<SatRadio>, satPos: OrbitalPos) {
|
||||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
val transmitters = satelliteRepo.getRadios(satPos, radios)
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val freq = if (state.transceivers.selectedUuid != null) {
|
val freq = if (state.transceivers.selectedUuid != null) {
|
||||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||||
@@ -411,6 +416,52 @@ class RadarViewModel(
|
|||||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun initCwDecoder() {
|
||||||
|
if (cwDecoder == null) {
|
||||||
|
cwDecoder = CwDecoder(
|
||||||
|
sampleRate = audioCapture.sampleRate,
|
||||||
|
cwToneFreq = _uiState.value.cw.cwToneFreq
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCwListening() {
|
||||||
|
if (cwListeningJob?.isActive == true) return
|
||||||
|
if (!_uiState.value.cw.hasPermission) {
|
||||||
|
// Permission not yet granted — request it (launcher handles the result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Stop SSTV if running (audio capture is shared)
|
||||||
|
stopSstvRecording()
|
||||||
|
initCwDecoder()
|
||||||
|
cwDecoder?.resetDecoder()
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Listening)) }
|
||||||
|
cwListeningJob = viewModelScope.launch {
|
||||||
|
// Collect decoded text flow
|
||||||
|
launch {
|
||||||
|
cwDecoder?.decodedTextFlow?.collect { text ->
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(decodedText = text)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collect signal strength
|
||||||
|
launch {
|
||||||
|
cwDecoder?.signalStrength?.collect { strength ->
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(signalStrength = strength)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Capture audio and feed to decoder
|
||||||
|
audioCapture.audioFlow().collect { buffer ->
|
||||||
|
cwDecoder?.processBuffer(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopCwListening() {
|
||||||
|
cwListeningJob?.cancel()
|
||||||
|
cwListeningJob = null
|
||||||
|
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// SSTV Decoder Tuning Parameters
|
// SSTV Decoder Tuning Parameters
|
||||||
// ==============================
|
// ==============================
|
||||||
@@ -418,7 +469,7 @@ class RadarViewModel(
|
|||||||
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
||||||
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
||||||
// - 0.50-0.60: Conservative, best for noisy environments
|
// - 0.50-0.60: Conservative, best for noisy environments
|
||||||
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
|
private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default
|
||||||
|
|
||||||
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
||||||
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
||||||
@@ -441,7 +492,7 @@ class RadarViewModel(
|
|||||||
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
||||||
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
||||||
// Ask users to compare both if they report line-skipping/compression artifacts.
|
// Ask users to compare both if they report line-skipping/compression artifacts.
|
||||||
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
|
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
|
||||||
|
|
||||||
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
||||||
private const val SSTV_INCLUDE_SCOPE = false
|
private const val SSTV_INCLUDE_SCOPE = false
|
||||||
@@ -453,6 +504,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(
|
||||||
|
|||||||
+247
-120
@@ -40,10 +40,13 @@ 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.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.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.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -79,8 +82,11 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
fun TransceiversPage(
|
fun TransceiversPage(
|
||||||
transceivers: List<SatRadio>,
|
transceivers: List<SatRadio>,
|
||||||
selectedUuid: String?,
|
selectedUuid: String?,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
cw: CwSubState,
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
|
requestMicPermission: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (transceivers.isEmpty()) {
|
if (transceivers.isEmpty()) {
|
||||||
@@ -103,8 +109,11 @@ fun TransceiversPage(
|
|||||||
TransceiverItem(
|
TransceiverItem(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
cw = cw,
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
|
requestMicPermission = requestMicPermission,
|
||||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -112,69 +121,6 @@ fun TransceiversPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CalculatorPage(
|
|
||||||
transceivers: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
onAction: (RadarAction) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val calculatorTransceivers = remember(transceivers) {
|
|
||||||
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
|
||||||
}
|
|
||||||
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
|
||||||
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
|
||||||
?: calculatorTransceivers.firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTransceiver == null) {
|
|
||||||
EmptyTransceiversContent(modifier)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
if (calculatorTransceivers.size > 1) {
|
|
||||||
item {
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
calculatorTransceivers.forEach { radio ->
|
|
||||||
FilterChip(
|
|
||||||
selected = radio.uuid == selectedTransceiver.uuid,
|
|
||||||
onClick = {
|
|
||||||
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = transceiverTitle(radio),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
DopplerCalculatorPanel(
|
|
||||||
transponder = selectedTransceiver,
|
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@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()) {
|
||||||
@@ -202,8 +148,11 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
|||||||
private fun TransceiverItem(
|
private fun TransceiverItem(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
cw: CwSubState,
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
|
requestMicPermission: () -> Unit,
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
@@ -243,8 +192,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,
|
||||||
@@ -292,8 +245,11 @@ private fun TransceiverItem(
|
|||||||
) {
|
) {
|
||||||
ExpandedRadioControl(
|
ExpandedRadioControl(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
cw = cw,
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction
|
onAction = onAction,
|
||||||
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,8 +318,11 @@ private fun UnifiedFrequencyRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ExpandedRadioControl(
|
private fun ExpandedRadioControl(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
cw: CwSubState,
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit
|
onAction: (RadarAction) -> Unit,
|
||||||
|
requestMicPermission: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -436,11 +395,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)) },
|
||||||
@@ -473,6 +431,23 @@ private fun ExpandedRadioControl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Doppler frequency calculator (linear transponders only)
|
||||||
|
DopplerFrequencyCalculator(
|
||||||
|
transponder = radio,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// CW decoder panel (linear transponders only)
|
||||||
|
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
|
||||||
|
CwDecoderPanel(
|
||||||
|
cw = cw,
|
||||||
|
onAction = onAction,
|
||||||
|
requestMicPermission = requestMicPermission,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Control buttons
|
// Control buttons
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
@@ -505,82 +480,116 @@ private fun ExpandedRadioControl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DopplerCalculatorPanel(
|
private fun DopplerFrequencyCalculator(
|
||||||
transponder: SatRadio,
|
transponder: SatRadio,
|
||||||
orbitalPos: OrbitalPos?,
|
orbitalPos: OrbitalPos?,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||||
|
|
||||||
var txInputMHz by remember(transponder.uuid) { mutableStateOf("") }
|
var txInputMHz by remember { mutableStateOf("") }
|
||||||
var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") }
|
var rxInputMHz by remember { mutableStateOf("") }
|
||||||
var offsetKHz by remember(transponder.uuid) { mutableStateOf("") }
|
var offsetKHz by remember { mutableStateOf("") }
|
||||||
var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) }
|
var lastEditedBy by remember { mutableStateOf(EditedField.TX) }
|
||||||
|
|
||||||
fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L
|
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||||
fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0)
|
|
||||||
fun mhzToHz(text: String): Long? = text.toDoubleOrNull()
|
|
||||||
?.takeIf { it > 0.0 }
|
|
||||||
?.let { (it * 1_000_000).toLong() }
|
|
||||||
|
|
||||||
fun calculateDownlink(txText: String, offsetText: String): String? {
|
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field
|
||||||
val txHz = mhzToHz(txText) ?: return null
|
LaunchedEffect(orbitalPos) {
|
||||||
return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
|
||||||
uplinkHz = txHz,
|
|
||||||
transponder = transponder,
|
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
offsetHz = offsetHz(offsetText)
|
|
||||||
)?.let(::formatHz)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun calculateUplink(rxText: String, offsetText: String): String? {
|
|
||||||
val rxHz = mhzToHz(rxText) ?: return null
|
|
||||||
return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
|
||||||
downlinkHz = rxHz,
|
|
||||||
transponder = transponder,
|
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
offsetHz = offsetHz(offsetText)
|
|
||||||
)?.let(::formatHz)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(orbitalPos, transponder.uuid) {
|
|
||||||
if (lastEditedBy == EditedField.TX) {
|
if (lastEditedBy == EditedField.TX) {
|
||||||
calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it }
|
val txMHz = txInputMHz.toDoubleOrNull()
|
||||||
|
if (txMHz != null && txMHz > 0) {
|
||||||
|
val txHz = (txMHz * 1_000_000).toLong()
|
||||||
|
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
txHz, transponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
|
if (rxHz != null) {
|
||||||
|
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it }
|
val rxMHz = rxInputMHz.toDoubleOrNull()
|
||||||
|
if (rxMHz != null && rxMHz > 0) {
|
||||||
|
val rxHz = (rxMHz * 1_000_000).toLong()
|
||||||
|
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
rxHz, transponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
|
if (txHz != null) {
|
||||||
|
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.radar_doppler_calc),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.radar_doppler_info),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
// Offset input
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = offsetKHz,
|
value = offsetKHz,
|
||||||
onValueChange = { newValue ->
|
onValueChange = { newVal ->
|
||||||
offsetKHz = newValue
|
offsetKHz = newVal
|
||||||
|
// Trigger recompute based on last edited field
|
||||||
if (lastEditedBy == EditedField.TX) {
|
if (lastEditedBy == EditedField.TX) {
|
||||||
calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it }
|
val txMHz = txInputMHz.toDoubleOrNull()
|
||||||
|
if (txMHz != null && txMHz > 0) {
|
||||||
|
val txHz = (txMHz * 1_000_000).toLong()
|
||||||
|
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
txHz, transponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
|
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it }
|
val rxMHz = rxInputMHz.toDoubleOrNull()
|
||||||
|
if (rxMHz != null && rxMHz > 0) {
|
||||||
|
val rxHz = (rxMHz * 1_000_000).toLong()
|
||||||
|
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
rxHz, transponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
|
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TX and RX inputs on the same row
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
|
// TX input → compute RX
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = txInputMHz,
|
value = txInputMHz,
|
||||||
onValueChange = { newValue ->
|
onValueChange = { newVal ->
|
||||||
txInputMHz = newValue
|
txInputMHz = newVal
|
||||||
lastEditedBy = EditedField.TX
|
lastEditedBy = EditedField.TX
|
||||||
rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz
|
val mhz = newVal.toDoubleOrNull()
|
||||||
|
if (mhz != null && mhz > 0) {
|
||||||
|
val txHz = (mhz * 1_000_000).toLong()
|
||||||
|
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
txHz, transponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
|
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
|
||||||
|
} else if (newVal.isEmpty()) {
|
||||||
|
rxInputMHz = ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
|
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -588,12 +597,22 @@ private fun DopplerCalculatorPanel(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RX input → compute TX
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = rxInputMHz,
|
value = rxInputMHz,
|
||||||
onValueChange = { newValue ->
|
onValueChange = { newVal ->
|
||||||
rxInputMHz = newValue
|
rxInputMHz = newVal
|
||||||
lastEditedBy = EditedField.RX
|
lastEditedBy = EditedField.RX
|
||||||
txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz
|
val mhz = newVal.toDoubleOrNull()
|
||||||
|
if (mhz != null && mhz > 0) {
|
||||||
|
val rxHz = (mhz * 1_000_000).toLong()
|
||||||
|
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
rxHz, transponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
|
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
|
||||||
|
} else if (newVal.isEmpty()) {
|
||||||
|
txInputMHz = ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
|
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -606,6 +625,120 @@ private fun DopplerCalculatorPanel(
|
|||||||
|
|
||||||
private enum class EditedField { TX, RX }
|
private enum class EditedField { TX, RX }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CwDecoderPanel(
|
||||||
|
cw: CwSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
requestMicPermission: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
// Header row: expand/collapse toggle
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onAction(RadarAction.CwToggleExpanded(!cw.isExpanded)) },
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.radar_cw_decoder),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.rotate(if (cw.isExpanded) 270f else 90f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = cw.isExpanded) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// Control buttons row
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (cw.status == CwStatus.Idle) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (!cw.hasPermission) {
|
||||||
|
requestMicPermission()
|
||||||
|
} else {
|
||||||
|
onAction(RadarAction.CwStartListening)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.radar_cw_start))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = { onAction(RadarAction.CwStopListening) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.radar_cw_stop))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onAction(RadarAction.CwReset) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.radar_cw_reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal strength indicator
|
||||||
|
if (cw.status == CwStatus.Listening && cw.signalStrength > 0f) {
|
||||||
|
val strengthPct = (cw.signalStrength * 100).toInt()
|
||||||
|
Text(
|
||||||
|
text = "Signal: $strengthPct%",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (cw.signalStrength > 0.5f) Color(0xFF4CAF50)
|
||||||
|
else if (cw.signalStrength > 0.2f) Color(0xFFFFC107)
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoded text output
|
||||||
|
ElevatedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Decoded:",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = cw.decodedText.ifEmpty { "Waiting for CW signal..." },
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@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 {
|
||||||
@@ -621,11 +754,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")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.roaming"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,791 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.roaming
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 漫游页 —— 原样照搬自 QTH定位器 2.0 (com.us1pm.gridsquarelocator)。
|
||||||
|
* UI: res/layout/main.xml (结构/尺寸/颜色逐项照搬, 不做任何更改)
|
||||||
|
* 逻辑: MainActivity.showLocation() / checkEnabled() / GPS 实时监听
|
||||||
|
* 红线: UI 与逻辑均不更改, 与成品截图逐像素对齐。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RoamingScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var state by remember { mutableStateOf(RoamingState()) }
|
||||||
|
// 照搬 onCreate: 日期固定文本 + 小时前缀(定位时拼接分钟)
|
||||||
|
val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) }
|
||||||
|
var hourPrefix by remember { mutableStateOf("") }
|
||||||
|
// 照搬 onCreate: 初始 checkEnabled() -> 红点+设置按钮, 日期隐藏
|
||||||
|
var hasPermission by remember { mutableStateOf(false) }
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
hourPrefix = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 3)
|
||||||
|
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
hasPermission = fine || coarse
|
||||||
|
if (!hasPermission) {
|
||||||
|
permissionLauncher.launch(
|
||||||
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 照搬 onResume: 绿点+进度条+提示+日期显示, 红点/按钮隐藏
|
||||||
|
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 照搬 onResume/onPause: GPS + network 监听, 10 秒 / 10 米
|
||||||
|
val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager }
|
||||||
|
DisposableEffect(locationManager, hasPermission) {
|
||||||
|
if (hasPermission) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
val listener = object : LocationListener {
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
// 照搬 showLocation 开头: 只接受 gps / network provider
|
||||||
|
if (location.provider != "gps" && location.provider != "network") return
|
||||||
|
state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix)
|
||||||
|
.copy(date = dateText)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProviderDisabled(provider: String) {
|
||||||
|
// 照搬 checkEnabled: 红点+按钮, 日期隐藏
|
||||||
|
state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProviderEnabled(provider: String) {
|
||||||
|
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
locationManager.requestLocationUpdates("gps", 10_000L, 10f, listener)
|
||||||
|
locationManager.requestLocationUpdates("network", 10_000L, 10f, listener)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
}
|
||||||
|
onDispose { locationManager.removeUpdates(listener) }
|
||||||
|
} else {
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 照搬 res/layout/main.xml: 浅色页面, 蓝色信息区, 43sp 定位码, 3x3 九宫格, 底部版权
|
||||||
|
// 按用户要求: 上下往内缩进系统栏安全区, 避免内容被状态栏/导航栏遮挡
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.windowInsetsPadding(WindowInsets.systemBars)
|
||||||
|
) {
|
||||||
|
// relativeLayout7: 顶部 GPS 状态条, 25dp 高, holo_blue_bright
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(2.dp)
|
||||||
|
.height(25.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// tvTitleGPS: "GPS" 14sp 黑, 左 8dp
|
||||||
|
Text(
|
||||||
|
text = "GPS",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (成品 main.xml)
|
||||||
|
Spacer(modifier = Modifier.width(15.dp))
|
||||||
|
// imGps: 绿点 (15dp, 主题色 primary -> 夜间滤镜下可见)
|
||||||
|
if (state.gpsOn) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(15.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// imGpsOff: 红点叠层 (15dp, 主题色 error)
|
||||||
|
if (state.gpsOff) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(15.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.error, CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
// tvDate: 14sp 黑, 居中
|
||||||
|
if (state.showDate) {
|
||||||
|
Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
// tvTime: 14sp 黑, 右对齐
|
||||||
|
Text(
|
||||||
|
text = state.time,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// btnLocationSettings: 黄色按钮, 居中
|
||||||
|
// btnLocationSettings: 成品 #faf8f54a 半透明黄, 14sp 黑字, 居中
|
||||||
|
if (state.showSettings) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.clickable { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
|
||||||
|
.padding(horizontal = 5.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = "设置启用GPS", fontSize = 14.sp, color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// linearLayout4: 经纬度区, holo_blue_bright
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
// 纬度行: tvLati 16sp 黑 左 8dp | tvLat 右对齐 3dp
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = state.latLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(text = state.latValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
|
||||||
|
}
|
||||||
|
// 经度行
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = state.lonLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(text = state.lonValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// relativeLayout6: 定位码区, 43sp bold 黑居中
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.loc,
|
||||||
|
fontSize = 43.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
// progressBar: 加载中, 居中
|
||||||
|
if (state.showProgress) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp).align(Alignment.Center),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// tvNotice: 提示文字, 居中顶部
|
||||||
|
if (state.showNotice) {
|
||||||
|
Text(
|
||||||
|
text = "请稍等,加载可能需要几分钟的时间。",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// relativeLayout: 九宫格, 占满剩余, 底部 15dp
|
||||||
|
// 用 Box 等价 RelativeLayout 布局: 左格贴左 / 中格水平居中 / 右格贴右 (成品 main.xml)
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) {
|
||||||
|
// 行1: relativeLayout5 (上排: 纬度 +1)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
GridCell(text = state.grids[0], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||||
|
GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
|
||||||
|
GridCell(text = state.grids[2], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||||
|
}
|
||||||
|
// 行2: relativeLayout4, 205dp 高 (中排: 中央)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().height(205.dp)) {
|
||||||
|
GridCell(text = state.grids[3], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||||
|
// 中央列: tv22 (100x80dp centerInParent) 叠在 linearLayout3 (200x200dp) 之上
|
||||||
|
Box(modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center)) {
|
||||||
|
// linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = 2.dp)
|
||||||
|
.size(width = 200.dp, height = 200.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
// iv: 红点 10x10dp, top|left, leftMargin/topMargin 查表绝对定位
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.roam_pnt),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.offset(x = state.markerLeft.dp, y = state.markerTop.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight(白)
|
||||||
|
Text(
|
||||||
|
text = state.grids[4],
|
||||||
|
fontSize = 30.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 100.dp, height = 80.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GridCell(text = state.grids[5], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||||
|
}
|
||||||
|
// 行3: relativeLayout3 (下排: 纬度 -1)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
GridCell(text = state.grids[6], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||||
|
GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
|
||||||
|
GridCell(text = state.grids[8], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// tv4: 底部版权, 10sp 黑 (往上拉, 底部预留空间避免被导航栏遮挡)
|
||||||
|
Text(
|
||||||
|
text = "制作:US1PM 汉化:BA7LCE",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 周边格: 60/200/60dp, #0BACF1, bold 居中, 2dp margin (照搬 tv11/tv13/tv21/tv23/tv31/tv33)
|
||||||
|
@Composable
|
||||||
|
private fun GridCell(text: String, modifier: Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬 MainActivity.showLocation() 的状态。
|
||||||
|
* 网格/九宫格/红点查表与成品逐行一致。
|
||||||
|
*/
|
||||||
|
data class RoamingState(
|
||||||
|
val date: String = "",
|
||||||
|
val time: String = "",
|
||||||
|
val latLabel: String = "纬度",
|
||||||
|
val latValue: String = "",
|
||||||
|
val lonLabel: String = "经度",
|
||||||
|
val lonValue: String = "",
|
||||||
|
val loc: String = "",
|
||||||
|
val grids: List<String> = List(9) { "" },
|
||||||
|
/** 红点 leftMargin(dp), 经度第 3 对字符查表 */
|
||||||
|
val markerLeft: Int = 0,
|
||||||
|
/** 红点 topMargin(dp), 纬度第 3 对字符查表(屏幕 Y 反转) */
|
||||||
|
val markerTop: Int = 0,
|
||||||
|
val gpsOn: Boolean = false,
|
||||||
|
val gpsOff: Boolean = true,
|
||||||
|
val showSettings: Boolean = true,
|
||||||
|
val showProgress: Boolean = false,
|
||||||
|
val showNotice: Boolean = false,
|
||||||
|
val showDate: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬 showLocation() 的完整计算: 时间/经纬度 DMS/8 位网格/九宫格/红点查表。
|
||||||
|
*/
|
||||||
|
fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState {
|
||||||
|
// 照搬: tvTime = 缓存小时前缀 + 定位时间分钟
|
||||||
|
val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime))
|
||||||
|
val time = hourPrefix + minute
|
||||||
|
// 方向字符
|
||||||
|
val ew = if (lon >= 0.0) "E" else "W"
|
||||||
|
val ns = if (lat >= 0.0) "N" else "S"
|
||||||
|
// 照搬: 纬度 DMS (String.valueOf 无补零)
|
||||||
|
val latAbs = if (lat < 0.0) -lat else lat
|
||||||
|
val latDeg = latAbs.toInt()
|
||||||
|
val latMinFull = (latAbs - latDeg) * 60.0
|
||||||
|
val latMin = latMinFull.toInt()
|
||||||
|
val latSec = ((latMinFull - latMin) * 60.0).toInt()
|
||||||
|
val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns"
|
||||||
|
val latValue = "$lat° "
|
||||||
|
// 照搬: 经度 DMS
|
||||||
|
val lonAbs = if (lon < 0.0) -lon else lon
|
||||||
|
val lonDeg = lonAbs.toInt()
|
||||||
|
val lonMinFull = (lonAbs - lonDeg) * 60.0
|
||||||
|
val lonMin = lonMinFull.toInt()
|
||||||
|
val lonSec = ((lonMinFull - lonMin) * 60.0).toInt()
|
||||||
|
val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew"
|
||||||
|
val lonValue = "$lon° "
|
||||||
|
// 照搬: 8 位网格编码 (区间查表)
|
||||||
|
val (str, str2, str3, str4) = encodeLon(lon)
|
||||||
|
val (str5, str6, str38, str9) = encodeLat(lat)
|
||||||
|
val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9
|
||||||
|
// 照搬: 九宫格 parseInt3 五分支
|
||||||
|
val grids = buildGrids(str, str5, str2, str6, str3, str38)
|
||||||
|
// 照搬: 红点查表 (第 3 对字符)
|
||||||
|
val markerLeft = lonMargin[str3.firstOrNull()] ?: 0
|
||||||
|
val markerTop = latMargin[str38.firstOrNull()] ?: 0
|
||||||
|
return RoamingState(
|
||||||
|
time = time,
|
||||||
|
latLabel = latLabel,
|
||||||
|
latValue = latValue,
|
||||||
|
lonLabel = lonLabel,
|
||||||
|
lonValue = lonValue,
|
||||||
|
loc = loc,
|
||||||
|
grids = grids,
|
||||||
|
markerLeft = markerLeft,
|
||||||
|
markerTop = markerTop,
|
||||||
|
gpsOn = true,
|
||||||
|
gpsOff = false,
|
||||||
|
showSettings = false,
|
||||||
|
showProgress = false,
|
||||||
|
showNotice = false,
|
||||||
|
showDate = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 红点 leftMargin 查表 (MainActivity 1286-1386 行): 经度第 3 对 a..x -> -2..190 dp
|
||||||
|
private val lonMargin = mapOf(
|
||||||
|
'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56,
|
||||||
|
'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128,
|
||||||
|
'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190
|
||||||
|
)
|
||||||
|
|
||||||
|
// 红点 topMargin 查表 (MainActivity 1315-1363 行): 纬度第 3 对 a..x -> 190..-2 dp (屏幕 Y 反转)
|
||||||
|
private val latMargin = mapOf(
|
||||||
|
'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137,
|
||||||
|
'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65,
|
||||||
|
'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2
|
||||||
|
)
|
||||||
|
|
||||||
|
// 照搬 311-611 行: 经度 -> (20°区字母, 2°数字, 2'字母, 30"数字)
|
||||||
|
private fun encodeLon(inputLon: Double): List<String> {
|
||||||
|
var longitude = inputLon
|
||||||
|
var d = 0.0
|
||||||
|
val str = when {
|
||||||
|
(longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" }
|
||||||
|
(longitude >= -160.0) && (longitude <= -140.0) -> { longitude += 160.0; "B" }
|
||||||
|
(longitude >= -140.0) && (longitude <= -120.0) -> { longitude += 140.0; "C" }
|
||||||
|
(longitude >= -120.0) && (longitude <= -100.0) -> { longitude += 120.0; "D" }
|
||||||
|
(longitude >= -100.0) && (longitude <= -80.0) -> { longitude += 100.0; "E" }
|
||||||
|
(longitude >= -80.0) && (longitude <= -60.0) -> { longitude += 80.0; "F" }
|
||||||
|
(longitude >= -60.0) && (longitude <= -40.0) -> { longitude += 60.0; "G" }
|
||||||
|
(longitude >= -40.0) && (longitude <= -20.0) -> { longitude += 40.0; "H" }
|
||||||
|
(longitude >= -20.0) && (longitude <= 0.0) -> { longitude += 20.0; "I" }
|
||||||
|
(longitude >= 0.0) && (longitude <= 20.0) -> "J"
|
||||||
|
(longitude >= 20.0) && (longitude <= 40.0) -> { longitude -= 20.0; "K" }
|
||||||
|
(longitude >= 40.0) && (longitude <= 60.0) -> { longitude -= 40.0; "L" }
|
||||||
|
(longitude >= 60.0) && (longitude <= 80.0) -> { longitude -= 60.0; "M" }
|
||||||
|
(longitude >= 80.0) && (longitude <= 100.0) -> { longitude -= 80.0; "N" }
|
||||||
|
(longitude >= 100.0) && (longitude <= 120.0) -> { longitude -= 100.0; "O" }
|
||||||
|
(longitude >= 120.0) && (longitude <= 140.0) -> { longitude -= 120.0; "P" }
|
||||||
|
(longitude >= 140.0) && (longitude <= 160.0) -> { longitude -= 140.0; "Q" }
|
||||||
|
(longitude >= 160.0) && (longitude <= 180.0) -> { longitude -= 160.0; "R" }
|
||||||
|
else -> { longitude = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str2 = when {
|
||||||
|
((longitude >= 0.0) && (longitude <= 2.0)) || ((longitude >= -20.0) && (longitude <= -18.0)) -> { d = longitude * 60.0; "0" }
|
||||||
|
((longitude >= 2.0) && (longitude <= 4.0)) || ((longitude >= -18.0) && (longitude <= -16.0)) -> { d = (longitude - 2.0) * 60.0; "1" }
|
||||||
|
((longitude >= 4.0) && (longitude <= 6.0)) || ((longitude >= -16.0) && (longitude <= -14.0)) -> { d = (longitude - 4.0) * 60.0; "2" }
|
||||||
|
((longitude >= 6.0) && (longitude <= 8.0)) || ((longitude >= -14.0) && (longitude <= -12.0)) -> { d = (longitude - 6.0) * 60.0; "3" }
|
||||||
|
((longitude >= 8.0) && (longitude <= 10.0)) || ((longitude >= -12.0) && (longitude <= -10.0)) -> { d = (longitude - 8.0) * 60.0; "4" }
|
||||||
|
((longitude >= 10.0) && (longitude <= 12.0)) || ((longitude >= -10.0) && (longitude <= -8.0)) -> { d = (longitude - 10.0) * 60.0; "5" }
|
||||||
|
((longitude >= 12.0) && (longitude <= 14.0)) || ((longitude >= -8.0) && (longitude <= -6.0)) -> { d = (longitude - 12.0) * 60.0; "6" }
|
||||||
|
((longitude >= 14.0) && (longitude <= 16.0)) || ((longitude >= -6.0) && (longitude <= -4.0)) -> { d = (longitude - 14.0) * 60.0; "7" }
|
||||||
|
((longitude >= 16.0) && (longitude <= 18.0)) || ((longitude >= -4.0) && (longitude <= -2.0)) -> { d = (longitude - 16.0) * 60.0; "8" }
|
||||||
|
((longitude >= 18.0) && (longitude <= 20.0)) || ((longitude >= -2.0) && (longitude <= 0.0)) -> { d = (longitude - 18.0) * 60.0; "9" }
|
||||||
|
else -> { d = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str3 = when {
|
||||||
|
((d >= 0.0) && (d <= 5.0)) || ((d >= -120.0) && (d <= -115.0)) -> { longitude = d * 60.0; "a" }
|
||||||
|
((d >= 5.0) && (d <= 10.0)) || ((d >= -115.0) && (d <= -110.0)) -> { longitude = (d - 5.0) * 60.0; "b" }
|
||||||
|
((d >= 10.0) && (d <= 15.0)) || ((d >= -110.0) && (d <= -105.0)) -> { longitude = (d - 10.0) * 60.0; "c" }
|
||||||
|
((d >= 15.0) && (d <= 20.0)) || ((d >= -105.0) && (d <= -100.0)) -> { longitude = (d - 15.0) * 60.0; "d" }
|
||||||
|
((d >= 20.0) && (d <= 25.0)) || ((d >= -100.0) && (d <= -95.0)) -> { longitude = (d - 20.0) * 60.0; "e" }
|
||||||
|
((d >= 25.0) && (d <= 30.0)) || ((d >= -95.0) && (d <= -90.0)) -> { longitude = (d - 25.0) * 60.0; "f" }
|
||||||
|
((d >= 30.0) && (d <= 35.0)) || ((d >= -90.0) && (d <= -85.0)) -> { longitude = (d - 30.0) * 60.0; "g" }
|
||||||
|
((d >= 35.0) && (d <= 40.0)) || ((d >= -85.0) && (d <= -80.0)) -> { longitude = (d - 35.0) * 60.0; "h" }
|
||||||
|
((d >= 40.0) && (d <= 45.0)) || ((d >= -80.0) && (d <= -75.0)) -> { longitude = (d - 40.0) * 60.0; "i" }
|
||||||
|
((d >= 45.0) && (d <= 50.0)) || ((d >= -75.0) && (d <= -70.0)) -> { longitude = (d - 45.0) * 60.0; "j" }
|
||||||
|
((d >= 50.0) && (d <= 55.0)) || ((d >= -70.0) && (d <= -65.0)) -> { longitude = (d - 50.0) * 60.0; "k" }
|
||||||
|
((d >= 55.0) && (d <= 60.0)) || ((d >= -65.0) && (d <= -60.0)) -> { longitude = (d - 55.0) * 60.0; "l" }
|
||||||
|
((d >= 60.0) && (d <= 65.0)) || ((d >= -60.0) && (d <= -55.0)) -> { longitude = (d - 60.0) * 60.0; "m" }
|
||||||
|
((d >= 65.0) && (d <= 70.0)) || ((d >= -55.0) && (d <= -50.0)) -> { longitude = (d - 65.0) * 60.0; "n" }
|
||||||
|
((d >= 70.0) && (d <= 75.0)) || ((d >= -50.0) && (d <= -45.0)) -> { longitude = (d - 70.0) * 60.0; "o" }
|
||||||
|
((d >= 75.0) && (d <= 80.0)) || ((d >= -45.0) && (d <= -40.0)) -> { longitude = (d - 75.0) * 60.0; "p" }
|
||||||
|
((d >= 80.0) && (d <= 85.0)) || ((d >= -40.0) && (d <= -35.0)) -> { longitude = (d - 80.0) * 60.0; "q" }
|
||||||
|
((d >= 85.0) && (d <= 90.0)) || ((d >= -35.0) && (d <= -30.0)) -> { longitude = (d - 85.0) * 60.0; "r" }
|
||||||
|
((d >= 90.0) && (d <= 95.0)) || ((d >= -30.0) && (d <= -25.0)) -> { longitude = (d - 90.0) * 60.0; "s" }
|
||||||
|
((d >= 95.0) && (d <= 100.0)) || ((d >= -25.0) && (d <= -20.0)) -> { longitude = (d - 95.0) * 60.0; "t" }
|
||||||
|
((d >= 100.0) && (d <= 105.0)) || ((d >= -20.0) && (d <= -15.0)) -> { longitude = (d - 100.0) * 60.0; "u" }
|
||||||
|
((d >= 105.0) && (d <= 110.0)) || ((d >= -15.0) && (d <= -10.0)) -> { longitude = (d - 105.0) * 60.0; "v" }
|
||||||
|
((d >= 110.0) && (d <= 115.0)) || ((d >= -10.0) && (d <= -5.0)) -> { longitude = (d - 110.0) * 60.0; "w" }
|
||||||
|
((d >= 115.0) && (d <= 120.0)) || ((d >= -5.0) && (d <= 0.0)) -> { longitude = (d - 115.0) * 60.0; "x" }
|
||||||
|
else -> { longitude = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str4 = when {
|
||||||
|
((longitude >= 0.0) && (longitude <= 30.0)) || ((longitude >= -300.0) && (longitude <= -270.0)) -> "0"
|
||||||
|
((longitude >= 30.0) && (longitude <= 60.0)) || ((longitude >= -270.0) && (longitude <= -240.0)) -> "1"
|
||||||
|
((longitude >= 60.0) && (longitude <= 90.0)) || ((longitude >= -240.0) && (longitude <= -210.0)) -> "2"
|
||||||
|
((longitude >= 90.0) && (longitude <= 120.0)) || ((longitude >= -210.0) && (longitude <= -180.0)) -> "3"
|
||||||
|
((longitude >= 120.0) && (longitude <= 150.0)) || ((longitude >= -180.0) && (longitude <= -150.0)) -> "4"
|
||||||
|
((longitude >= 150.0) && (longitude <= 180.0)) || ((longitude >= -150.0) && (longitude <= -120.0)) -> "5"
|
||||||
|
((longitude >= 180.0) && (longitude <= 210.0)) || ((longitude >= -120.0) && (longitude <= -90.0)) -> "6"
|
||||||
|
((longitude >= 210.0) && (longitude <= 240.0)) || ((longitude >= -90.0) && (longitude <= -60.0)) -> "7"
|
||||||
|
((longitude >= 240.0) && (longitude <= 270.0)) || ((longitude >= -60.0) && (longitude <= -30.0)) -> "8"
|
||||||
|
((longitude >= 270.0) && (longitude <= 300.0)) || ((longitude >= -30.0) && (longitude <= 0.0)) -> "9"
|
||||||
|
else -> " "
|
||||||
|
}
|
||||||
|
return listOf(str, str2, str3, str4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 照搬 612-914 行: 纬度 -> (10°区字母, 1°数字, 1'字母, 15"数字)
|
||||||
|
private fun encodeLat(inputLat: Double): List<String> {
|
||||||
|
var latitude = inputLat
|
||||||
|
var d2 = 0.0
|
||||||
|
var d3 = 0.0
|
||||||
|
val str5 = when {
|
||||||
|
(latitude >= -90.0) && (latitude <= -80.0) -> { d2 = latitude + 90.0; "A" }
|
||||||
|
(latitude >= -80.0) && (latitude <= -70.0) -> { d2 = latitude + 80.0; "B" }
|
||||||
|
(latitude >= -70.0) && (latitude <= -60.0) -> { d2 = latitude + 70.0; "C" }
|
||||||
|
(latitude >= -60.0) && (latitude <= -50.0) -> { d2 = latitude + 60.0; "D" }
|
||||||
|
(latitude >= -50.0) && (latitude <= -40.0) -> { d2 = latitude + 50.0; "E" }
|
||||||
|
(latitude >= -40.0) && (latitude <= -30.0) -> { d2 = latitude + 40.0; "F" }
|
||||||
|
(latitude >= -30.0) && (latitude <= -20.0) -> { d2 = latitude + 30.0; "G" }
|
||||||
|
(latitude >= -20.0) && (latitude <= -10.0) -> { d2 = latitude + 20.0; "H" }
|
||||||
|
(latitude >= -10.0) && (latitude <= 0.0) -> { d2 = latitude + 10.0; "I" }
|
||||||
|
(latitude >= 0.0) && (latitude <= 10.0) -> { d2 = latitude; "J" }
|
||||||
|
(latitude >= 10.0) && (latitude <= 20.0) -> { d2 = latitude - 10.0; "K" }
|
||||||
|
(latitude >= 20.0) && (latitude <= 30.0) -> { d2 = latitude - 20.0; "L" }
|
||||||
|
(latitude >= 30.0) && (latitude <= 40.0) -> { d2 = latitude - 30.0; "M" }
|
||||||
|
(latitude >= 40.0) && (latitude <= 50.0) -> { d2 = latitude - 40.0; "N" }
|
||||||
|
(latitude >= 50.0) && (latitude <= 60.0) -> { d2 = latitude - 50.0; "O" }
|
||||||
|
(latitude >= 60.0) && (latitude <= 70.0) -> { d2 = latitude - 60.0; "P" }
|
||||||
|
(latitude >= 70.0) && (latitude <= 80.0) -> { d2 = latitude - 70.0; "Q" }
|
||||||
|
(latitude >= 80.0) && (latitude <= 90.0) -> { d2 = latitude - 80.0; "R" }
|
||||||
|
else -> { d2 = 0.0; " " }
|
||||||
|
}
|
||||||
|
var str6 = " "
|
||||||
|
when {
|
||||||
|
((d2 >= 0.0) && (d2 <= 1.0)) || ((d2 >= -10.0) && (d2 <= -9.0)) -> { d3 = d2 * 60.0; str6 = "0" }
|
||||||
|
((d2 >= 1.0) && (d2 <= 2.0)) || ((d2 >= -9.0) && (d2 <= -8.0)) -> { d3 = (d2 - 1.0) * 60.0; str6 = "1" }
|
||||||
|
((d2 >= 2.0) && (d2 <= 3.0)) || ((d2 >= -8.0) && (d2 <= -7.0)) -> { d3 = (d2 - 2.0) * 60.0; str6 = "2" }
|
||||||
|
((d2 >= 3.0) && (d2 <= 4.0)) || ((d2 >= -7.0) && (d2 <= -6.0)) -> { d3 = (d2 - 3.0) * 60.0; str6 = "3" }
|
||||||
|
((d2 >= 4.0) && (d2 <= 5.0)) || ((d2 >= -6.0) && (d2 <= -5.0)) -> { d3 = (d2 - 4.0) * 60.0; str6 = "4" }
|
||||||
|
((d2 >= 5.0) && (d2 <= 6.0)) || ((d2 >= -5.0) && (d2 <= -4.0)) -> { d3 = (d2 - 5.0) * 60.0; str6 = "5" }
|
||||||
|
((d2 >= 6.0) && (d2 <= 7.0)) || ((d2 >= -4.0) && (d2 <= -3.0)) -> { d3 = (d2 - 6.0) * 60.0; str6 = "6" }
|
||||||
|
((d2 >= 7.0) && (d2 <= 8.0)) || ((d2 >= -3.0) && (d2 <= -2.0)) -> { d3 = (d2 - 7.0) * 60.0; str6 = "7" }
|
||||||
|
((d2 >= 8.0) && (d2 <= 9.0)) || ((d2 >= -2.0) && (d2 <= -1.0)) -> { d3 = (d2 - 8.0) * 60.0; str6 = "8" }
|
||||||
|
((d2 >= 9.0) && (d2 <= 10.0)) || ((d2 >= -1.0) && (d2 <= 0.0)) -> { d3 = (d2 - 9.0) * 60.0; str6 = "9" }
|
||||||
|
}
|
||||||
|
val str38 = when {
|
||||||
|
((d3 >= 0.0) && (d3 <= 2.5)) || ((d3 >= -60.0) && (d3 <= -57.5)) -> { d2 = d3 * 60.0; "a" }
|
||||||
|
((d3 >= 2.5) && (d3 <= 5.0)) || ((d3 >= -57.5) && (d3 <= -55.0)) -> { d2 = (d3 - 2.5) * 60.0; "b" }
|
||||||
|
((d3 >= 5.0) && (d3 <= 7.5)) || ((d3 >= -55.0) && (d3 <= -52.5)) -> { d2 = (d3 - 5.0) * 60.0; "c" }
|
||||||
|
((d3 >= 7.5) && (d3 <= 10.0)) || ((d3 >= -52.5) && (d3 <= -50.0)) -> { d2 = (d3 - 7.5) * 60.0; "d" }
|
||||||
|
((d3 >= 10.0) && (d3 <= 12.5)) || ((d3 >= -50.0) && (d3 <= -47.5)) -> { d2 = (d3 - 10.0) * 60.0; "e" }
|
||||||
|
((d3 >= 12.5) && (d3 <= 15.0)) || ((d3 >= -47.5) && (d3 <= -45.0)) -> { d2 = (d3 - 12.5) * 60.0; "f" }
|
||||||
|
((d3 >= 15.0) && (d3 <= 17.5)) || ((d3 >= -45.0) && (d3 <= -42.5)) -> { d2 = (d3 - 15.0) * 60.0; "g" }
|
||||||
|
((d3 >= 17.5) && (d3 <= 20.0)) || ((d3 >= -42.5) && (d3 <= -40.0)) -> { d2 = (d3 - 17.5) * 60.0; "h" }
|
||||||
|
((d3 >= 20.0) && (d3 <= 22.5)) || ((d3 >= -40.0) && (d3 <= -37.5)) -> { d2 = (d3 - 20.0) * 60.0; "i" }
|
||||||
|
((d3 >= 22.5) && (d3 <= 25.0)) || ((d3 >= -37.5) && (d3 <= -35.0)) -> { d2 = (d3 - 22.5) * 60.0; "j" }
|
||||||
|
((d3 >= 25.0) && (d3 <= 27.5)) || ((d3 >= -35.0) && (d3 <= -32.5)) -> { d2 = (d3 - 25.0) * 60.0; "k" }
|
||||||
|
((d3 >= 27.5) && (d3 <= 30.0)) || ((d3 >= -32.5) && (d3 <= -30.0)) -> { d2 = (d3 - 27.5) * 60.0; "l" }
|
||||||
|
((d3 >= 30.0) && (d3 <= 32.5)) || ((d3 >= -30.0) && (d3 <= -27.5)) -> { d2 = (d3 - 30.0) * 60.0; "m" }
|
||||||
|
((d3 >= 32.5) && (d3 <= 35.0)) || ((d3 >= -27.5) && (d3 <= -25.0)) -> { d2 = (d3 - 32.5) * 60.0; "n" }
|
||||||
|
((d3 >= 35.0) && (d3 <= 37.5)) || ((d3 >= -25.0) && (d3 <= -22.5)) -> { d2 = (d3 - 35.0) * 60.0; "o" }
|
||||||
|
((d3 >= 37.5) && (d3 <= 40.0)) || ((d3 >= -22.5) && (d3 <= -20.0)) -> { d2 = (d3 - 37.5) * 60.0; "p" }
|
||||||
|
((d3 >= 40.0) && (d3 <= 42.5)) || ((d3 >= -20.0) && (d3 <= -17.5)) -> { d2 = (d3 - 40.0) * 60.0; "q" }
|
||||||
|
((d3 >= 42.5) && (d3 <= 45.0)) || ((d3 >= -17.5) && (d3 <= -15.0)) -> { d2 = (d3 - 42.5) * 60.0; "r" }
|
||||||
|
((d3 >= 45.0) && (d3 <= 47.5)) || ((d3 >= -15.0) && (d3 <= -12.5)) -> { d2 = (d3 - 45.0) * 60.0; "s" }
|
||||||
|
((d3 >= 47.5) && (d3 <= 50.0)) || ((d3 >= -12.5) && (d3 <= -10.0)) -> { d2 = (d3 - 47.5) * 60.0; "t" }
|
||||||
|
((d3 >= 50.0) && (d3 <= 52.5)) || ((d3 >= -10.0) && (d3 <= -7.5)) -> { d2 = (d3 - 50.0) * 60.0; "u" }
|
||||||
|
((d3 >= 52.5) && (d3 <= 55.0)) || ((d3 >= -7.5) && (d3 <= -5.0)) -> { d2 = (d3 - 52.5) * 60.0; "v" }
|
||||||
|
((d3 >= 55.0) && (d3 <= 57.5)) || ((d3 >= -5.0) && (d3 <= -2.5)) -> { d2 = (d3 - 55.0) * 60.0; "w" }
|
||||||
|
((d3 >= 57.5) && (d3 <= 60.0)) || ((d3 >= -2.5) && (d3 <= 0.0)) -> { d2 = (d3 - 57.5) * 60.0; "x" }
|
||||||
|
else -> { d2 = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str9 = when {
|
||||||
|
((d2 >= 0.0) && (d2 <= 15.0)) || ((d2 >= -150.0) && (d2 <= -135.0)) -> "0"
|
||||||
|
((d2 >= 15.0) && (d2 <= 30.0)) || ((d2 >= -135.0) && (d2 <= -120.0)) -> "1"
|
||||||
|
((d2 >= 30.0) && (d2 <= 45.0)) || ((d2 >= -120.0) && (d2 <= -105.0)) -> "2"
|
||||||
|
((d2 >= 45.0) && (d2 <= 60.0)) || ((d2 >= -105.0) && (d2 <= -90.0)) -> "3"
|
||||||
|
((d2 >= 60.0) && (d2 <= 75.0)) || ((d2 >= -90.0) && (d2 <= -75.0)) -> "4"
|
||||||
|
((d2 >= 75.0) && (d2 <= 90.0)) || ((d2 >= -75.0) && (d2 <= -60.0)) -> "5"
|
||||||
|
((d2 >= 90.0) && (d2 <= 105.0)) || ((d2 >= -60.0) && (d2 <= -45.0)) -> "6"
|
||||||
|
((d2 >= 105.0) && (d2 <= 120.0)) || ((d2 >= -45.0) && (d2 <= -30.0)) -> "7"
|
||||||
|
((d2 >= 120.0) && (d2 <= 135.0)) || ((d2 >= -30.0) && (d2 <= -15.0)) -> "8"
|
||||||
|
((d2 >= 135.0) && (d2 <= 150.0)) || ((d2 >= -15.0) && (d2 <= 0.0)) -> "9"
|
||||||
|
else -> " "
|
||||||
|
}
|
||||||
|
return listOf(str5, str6, str38, str9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬 917-1283 行: 九宫格 parseInt3 五分支(内部/西界/东界/北界/南界 + 四角)。
|
||||||
|
* 返回 9 格文本: [上左, 上中, 上右, 左中, 中央, 右中, 下左, 下中, 下右]。
|
||||||
|
*/
|
||||||
|
private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List<String> {
|
||||||
|
val parseInt = str2.toInt()
|
||||||
|
val parseInt2 = str6.toInt()
|
||||||
|
val str39 = str + str5
|
||||||
|
val str40 = str2 + str6
|
||||||
|
val parseInt3 = str40.toInt()
|
||||||
|
val str41 = str39 + str40
|
||||||
|
val g = MutableList(9) { "" }
|
||||||
|
fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString()
|
||||||
|
if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||||
|
// 内部: 数字对 ±1
|
||||||
|
val i7 = parseInt3 + 1
|
||||||
|
val i8 = i7 - 10
|
||||||
|
val valueOf9 = fmt2(i8)
|
||||||
|
val str11 = i7.toString()
|
||||||
|
val valueOf18 = (i7 + 10).toString()
|
||||||
|
val i9 = parseInt3 - 10
|
||||||
|
val valueOf10 = fmt2(i9)
|
||||||
|
val valueOf19 = (parseInt3 + 10).toString()
|
||||||
|
val i10 = parseInt3 - 1
|
||||||
|
val i11 = i10 - 10
|
||||||
|
val valueOf11 = fmt2(i11)
|
||||||
|
val str14 = i10.toString()
|
||||||
|
val str15 = (i10 + 10).toString()
|
||||||
|
g[0] = str39 + valueOf9
|
||||||
|
g[1] = str39 + str11
|
||||||
|
g[2] = str39 + valueOf18
|
||||||
|
g[3] = str39 + valueOf10
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf19
|
||||||
|
g[6] = str39 + valueOf11
|
||||||
|
g[7] = str39 + str14
|
||||||
|
g[8] = str39 + str15
|
||||||
|
} else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||||
|
// 西边界: 经度 0 -> 9, 经度字母 -1
|
||||||
|
val str43 = (str[0] - 1).toString() + str5
|
||||||
|
val str44 = "9" + (parseInt2 + 1)
|
||||||
|
val i12 = parseInt3 + 1
|
||||||
|
val valueOf7 = fmt2(i12)
|
||||||
|
val str12 = (i12 + 10).toString()
|
||||||
|
val sb4 = "9" + parseInt2
|
||||||
|
val valueOf20 = (parseInt3 + 10).toString()
|
||||||
|
val str46 = "9" + (parseInt2 - 1)
|
||||||
|
val i13 = parseInt3 - 1
|
||||||
|
val valueOf8 = fmt2(i13)
|
||||||
|
val str15 = (i13 + 10).toString()
|
||||||
|
g[0] = str43 + str44
|
||||||
|
g[1] = str39 + valueOf7
|
||||||
|
g[2] = str39 + str12
|
||||||
|
g[3] = str43 + sb4
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf20
|
||||||
|
g[6] = str43 + str46
|
||||||
|
g[7] = str39 + valueOf8
|
||||||
|
g[8] = str39 + str15
|
||||||
|
} else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||||
|
// 东边界: 经度 9 -> 0, 经度字母 +1
|
||||||
|
val str47 = (str[0] + 1).toString() + str5
|
||||||
|
val i14 = parseInt3 + 1
|
||||||
|
val valueOf21 = (i14 - 10).toString()
|
||||||
|
val valueOf22 = i14.toString()
|
||||||
|
val str48 = "0" + (parseInt2 + 1)
|
||||||
|
val valueOf23 = (parseInt3 - 10).toString()
|
||||||
|
val str49 = "0" + parseInt2
|
||||||
|
val i15 = parseInt3 - 1
|
||||||
|
val valueOf24 = (i15 - 10).toString()
|
||||||
|
val valueOf25 = i15.toString()
|
||||||
|
val str15 = "0" + (parseInt2 - 1)
|
||||||
|
g[0] = str39 + valueOf21
|
||||||
|
g[1] = str39 + valueOf22
|
||||||
|
g[2] = str47 + str48
|
||||||
|
g[3] = str39 + valueOf23
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str47 + str49
|
||||||
|
g[6] = str39 + valueOf24
|
||||||
|
g[7] = str39 + valueOf25
|
||||||
|
g[8] = str47 + str15
|
||||||
|
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) {
|
||||||
|
// 北边界: 纬度 9 -> 0, 纬度字母 +1
|
||||||
|
val str50 = str + (str5[0] + 1).toString()
|
||||||
|
val i16 = parseInt3 - 9
|
||||||
|
val i17 = i16 - 10
|
||||||
|
val valueOf4 = fmt2(i17)
|
||||||
|
val valueOf26 = i16.toString()
|
||||||
|
val valueOf27 = (i16 + 10).toString()
|
||||||
|
val i18 = parseInt3 - 10
|
||||||
|
val valueOf5 = fmt2(i18)
|
||||||
|
val valueOf28 = (parseInt3 + 10).toString()
|
||||||
|
val i19 = parseInt3 - 1
|
||||||
|
val i20 = i19 - 10
|
||||||
|
val valueOf6 = fmt2(i20)
|
||||||
|
val str14 = i19.toString()
|
||||||
|
val str15 = (i19 + 10).toString()
|
||||||
|
g[0] = str39 + valueOf4
|
||||||
|
g[1] = str50 + valueOf26
|
||||||
|
g[2] = str39 + valueOf27
|
||||||
|
g[3] = str39 + valueOf5
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf28
|
||||||
|
g[6] = str39 + valueOf6
|
||||||
|
g[7] = str39 + str14
|
||||||
|
g[8] = str39 + str15
|
||||||
|
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) {
|
||||||
|
// 南边界: 纬度 0 -> 9, 纬度字母 -1
|
||||||
|
val str52 = str + (str5[0] - 1).toString()
|
||||||
|
val i21 = parseInt3 + 1
|
||||||
|
val i22 = i21 - 10
|
||||||
|
val valueOf = fmt2(i22)
|
||||||
|
val valueOf29 = i21.toString()
|
||||||
|
val valueOf30 = (i21 + 10).toString()
|
||||||
|
val i23 = parseInt3 - 10
|
||||||
|
val valueOf2 = fmt2(i23)
|
||||||
|
val valueOf31 = (parseInt3 + 10).toString()
|
||||||
|
val i24 = parseInt3 - 1
|
||||||
|
val valueOf3 = fmt2(i24)
|
||||||
|
val str14 = (i24 + 10).toString()
|
||||||
|
val str15 = (i24 + 20).toString()
|
||||||
|
g[0] = str39 + valueOf
|
||||||
|
g[1] = str39 + valueOf29
|
||||||
|
g[2] = str39 + valueOf30
|
||||||
|
g[3] = str39 + valueOf2
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf31
|
||||||
|
g[6] = str52 + valueOf3
|
||||||
|
g[7] = str52 + str14
|
||||||
|
g[8] = str52 + str15
|
||||||
|
} else if (parseInt3 == 0) {
|
||||||
|
// 四角 00: 经纬度数字均 -1 进位
|
||||||
|
val v32 = (str[0] - 1).toString()
|
||||||
|
val v33 = (str5[0] - 1).toString()
|
||||||
|
g[0] = v32 + str5 + "91"
|
||||||
|
g[1] = str39 + "01"
|
||||||
|
g[2] = str39 + "11"
|
||||||
|
g[3] = v32 + str5 + "90"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v32 + str5 + "10"
|
||||||
|
g[6] = v32 + v33 + "99"
|
||||||
|
g[7] = str + v33 + "09"
|
||||||
|
g[8] = str + v33 + "19"
|
||||||
|
} else if (parseInt3 == 9) {
|
||||||
|
// 四角 09: 经度 -1, 纬度 +1
|
||||||
|
val v34 = (str[0] - 1).toString()
|
||||||
|
val v35 = (str5[0] + 1).toString()
|
||||||
|
g[0] = v34 + v35 + "90"
|
||||||
|
g[1] = str + v35 + "00"
|
||||||
|
g[2] = str + v35 + "10"
|
||||||
|
g[3] = v34 + str5 + "99"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v34 + str5 + "19"
|
||||||
|
g[6] = v34 + str5 + "98"
|
||||||
|
g[7] = v34 + str5 + "08"
|
||||||
|
g[8] = v34 + str5 + "18"
|
||||||
|
} else if (parseInt3 == 99) {
|
||||||
|
// 四角 99: 经纬度数字均 +1 进位
|
||||||
|
val v36 = (str[0] + 1).toString()
|
||||||
|
val v37 = (str5[0] + 1).toString()
|
||||||
|
g[0] = str + v37 + "80"
|
||||||
|
g[1] = str + v37 + "90"
|
||||||
|
g[2] = v36 + v37 + "00"
|
||||||
|
g[3] = str39 + "89"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v36 + str5 + "09"
|
||||||
|
g[6] = str39 + "88"
|
||||||
|
g[7] = str39 + "98"
|
||||||
|
g[8] = v36 + str5 + "08"
|
||||||
|
} else if (parseInt3 == 90) {
|
||||||
|
// 四角 90: 经度 +1, 纬度 -1
|
||||||
|
val v38 = (str[0] + 1).toString()
|
||||||
|
val v39 = (str5[0] - 1).toString()
|
||||||
|
g[0] = str39 + "81"
|
||||||
|
g[1] = str39 + "91"
|
||||||
|
g[2] = v38 + str5 + "01"
|
||||||
|
g[3] = str39 + "80"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v38 + str5 + "00"
|
||||||
|
g[6] = str + v39 + "89"
|
||||||
|
g[7] = str + v39 + "99"
|
||||||
|
g[8] = v38 + v39 + "09"
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 176 B |
Binary file not shown.
|
After Width: | Height: | Size: 172 B |
Binary file not shown.
|
After Width: | Height: | Size: 375 B |
+124
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.roaming
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RoamingStateTest {
|
||||||
|
|
||||||
|
// 用户实测坐标 (QTH定位器 2.0 成品截图): 22.314066 / 108.706575
|
||||||
|
private fun stateOf(lat: Double, lon: Double) =
|
||||||
|
roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `user coordinates produce OL42ih45`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
assertEquals("OL42ih45", state.loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `red marker from ih pair matches reference lookup`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
// 成品查表: 经度第3对 'i' -> leftMargin 65dp, 纬度第3对 'h' -> topMargin 137dp
|
||||||
|
assertEquals(65, state.markerLeft)
|
||||||
|
assertEquals(137, state.markerTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grid nine from OL42 matches reference`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
val expected = listOf(
|
||||||
|
"OL33", "OL43", "OL53",
|
||||||
|
"OL32", "OL42", "OL52",
|
||||||
|
"OL31", "OL41", "OL51"
|
||||||
|
)
|
||||||
|
assertEquals(expected, state.grids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `latitude dms formatting matches reference`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
assertEquals("纬度 22° 18' 50\" N", state.latLabel)
|
||||||
|
assertEquals("22.314066° ", state.latValue)
|
||||||
|
assertEquals("经度 108° 42' 23\" E", state.lonLabel)
|
||||||
|
assertEquals("108.706575° ", state.lonValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time is hour prefix plus fix minutes`() {
|
||||||
|
// fixTime = 2026-08-03 21:12:13 UTC+8 -> minutes 12
|
||||||
|
val fix = 1785820333000L // 2026-08-03 21:12:13 +0800
|
||||||
|
val state = roamingStateFromLocation(22.314066, 108.706575, fix, "21:")
|
||||||
|
assertEquals("21:12", state.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `west edge borrows lon letter`() {
|
||||||
|
// 经度 0.5 纬度 22.314066: 数字对 02, 西边界 parseInt==0
|
||||||
|
val state = stateOf(22.314066, 0.5)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// 左列经度 0->9, 经度字母 J-1=I: IL92
|
||||||
|
assertEquals("IL92", state.grids[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `north edge carries lat letter`() {
|
||||||
|
// 纬度 29.5 经度 108.706575: 数字对 49? -> 纬度数字 9 -> 北边界
|
||||||
|
val state = stateOf(29.5, 108.706575)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// 上排纬度 +1 -> 0, 纬度字母 +1 (M)
|
||||||
|
assertEquals("OM40", state.grids[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `east edge carries lon letter`() {
|
||||||
|
// 经度 119.5 纬度 22.314066: 经度数字 9 -> 东边界
|
||||||
|
val state = stateOf(22.314066, 119.5)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// 右上 经度 9->0, 经度字母 O+1=P, 纬度 2+1=3: PL03
|
||||||
|
assertEquals("PL03", state.grids[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `south edge borrows lat letter`() {
|
||||||
|
// 纬度 0.5 经度 108.706575: 纬度数字 0 -> 南边界
|
||||||
|
val state = stateOf(0.5, 108.706575)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// 下中 纬度 0->9, 纬度字母 J-1=I: OI49
|
||||||
|
assertEquals("OI49", state.grids[7])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `boundary values wrap like reference`() {
|
||||||
|
// (90, 180) -> RR99xx99 (成品区间查表行为)
|
||||||
|
val state = stateOf(90.0, 180.0)
|
||||||
|
assertEquals("RR99xx99", state.loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gps state after fix matches showLocation`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
assertEquals(true, state.gpsOn)
|
||||||
|
assertEquals(false, state.gpsOff)
|
||||||
|
assertEquals(false, state.showSettings)
|
||||||
|
assertEquals(false, state.showProgress)
|
||||||
|
assertEquals(false, state.showNotice)
|
||||||
|
assertEquals(true, state.showDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-9
@@ -45,18 +45,18 @@ 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()) }
|
||||||
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
@@ -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
|
||||||
|
|||||||
+22
-65
@@ -87,11 +87,11 @@ 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) {
|
||||||
@@ -108,7 +108,6 @@ private fun SatellitesScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(horizontal = padding)
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +122,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 +156,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 +240,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 +251,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
|
||||||
@@ -291,14 +290,17 @@ private fun Satellite(
|
|||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
|
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
|
||||||
) {
|
) {
|
||||||
Text(text = "$passSatId - ", color = MaterialTheme.colorScheme.primary)
|
Text(
|
||||||
|
text = "$passSatId - ",
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.name,
|
text = item.name,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = item.isSelected,
|
checked = item.isSelected,
|
||||||
@@ -323,54 +325,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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+146
-169
@@ -17,9 +17,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.content.Context
|
||||||
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.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -27,23 +30,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
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 com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
@@ -64,27 +70,18 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
|
|||||||
val titleText = stringResource(id = R.string.prefs_station_title)
|
val titleText = stringResource(id = R.string.prefs_station_title)
|
||||||
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
||||||
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
||||||
Row(
|
OutlinedTextField(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
value = latValue.value,
|
||||||
modifier = Modifier
|
onValueChange = { latValue.value = it },
|
||||||
.fillMaxWidth()
|
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
|
||||||
.padding(horizontal = LocalSpacing.current.large)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
) {
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = latValue.value,
|
value = lonValue.value,
|
||||||
onValueChange = { latValue.value = it },
|
onValueChange = { lonValue.value = it },
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
|
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
|
||||||
singleLine = true,
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
modifier = Modifier.weight(1f),
|
)
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = lonValue.value,
|
|
||||||
onValueChange = { lonValue.value = it },
|
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,18 +104,12 @@ private fun LocatorDialogPreview() {
|
|||||||
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
||||||
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
||||||
val onAccept = { save(locator.value).also { dismiss() } }
|
val onAccept = { save(locator.value).also { dismiss() } }
|
||||||
SharedDialog(
|
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
|
||||||
title = stringResource(R.string.prefs_locator_title),
|
|
||||||
onCancel = dismiss,
|
|
||||||
onAccept = onAccept
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = locator.value,
|
value = locator.value,
|
||||||
onValueChange = { locator.value = it },
|
onValueChange = { locator.value = it },
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
|
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = LocalSpacing.current.large),
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
@@ -160,12 +151,7 @@ fun DataSourcesDialog(
|
|||||||
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
||||||
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
|
||||||
isEnabledCustomTle.value,
|
|
||||||
isEnabledCustomTransceivers.value,
|
|
||||||
urlTle.value,
|
|
||||||
urlTransceivers.value
|
|
||||||
)
|
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
val onCancel = { onDismiss() }
|
val onCancel = { onDismiss() }
|
||||||
@@ -221,6 +207,17 @@ fun DataSourcesDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = isEnabledCustomTle.value,
|
enabled = isEnabledCustomTle.value,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { urlTle.value = Sources.defaultTleUrl },
|
||||||
|
enabled = isEnabledCustomTle.value
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_reset),
|
||||||
|
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Row(
|
Row(
|
||||||
@@ -250,6 +247,17 @@ fun DataSourcesDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = isEnabledCustomTransceivers.value,
|
enabled = isEnabledCustomTransceivers.value,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { urlTransceivers.value = Sources.defaultTransceiversUrl },
|
||||||
|
enabled = isEnabledCustomTransceivers.value
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_reset),
|
||||||
|
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
@@ -395,15 +403,11 @@ fun BluetoothOutputDialog(
|
|||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
|
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
|
||||||
val rotatorAddress =
|
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
|
||||||
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
||||||
val frequencyState =
|
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
||||||
val frequencyAddress =
|
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
|
||||||
val frequencyFormat =
|
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
||||||
@@ -492,22 +496,23 @@ private fun OutputChannelSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RadioControlDialog(
|
fun RadioControlDialog(
|
||||||
initialSettings: RadioControlSettings,
|
initialSettings: RadioControlSettings,
|
||||||
pairedBluetoothDevices: List<Pair<String, String>>,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (RadioControlSettings) -> Unit
|
onSave: (RadioControlSettings) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
val padding = LocalSpacing.current.large
|
||||||
|
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||||
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||||
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
||||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||||
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||||
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||||
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
||||||
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
||||||
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||||
|
|
||||||
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
@@ -517,157 +522,157 @@ fun RadioControlDialog(
|
|||||||
if (!isIcom && splitMode.value) splitMode.value = false
|
if (!isIcom && splitMode.value) splitMode.value = false
|
||||||
|
|
||||||
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
||||||
else RadioControlSettings.BAUD_RATES_YAESU
|
else RadioControlSettings.BAUD_RATES_YAESU
|
||||||
|
|
||||||
// If current baud rate is not in the new list, default to the first available
|
// If current baud rate is not in the new list, default to the first available
|
||||||
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
||||||
|
|
||||||
|
val pairedDevices: List<Pair<String, String>> = remember {
|
||||||
|
buildList {
|
||||||
|
try {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
manager.adapter?.bondedDevices?.forEach {
|
||||||
|
add(Pair(it.name ?: "Unknown", it.address ?: ""))
|
||||||
|
}
|
||||||
|
} catch (_: SecurityException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
RadioControlSettings(
|
RadioControlSettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
radioModel = radioModel.value,
|
radioModel = radioModel.value,
|
||||||
txRadioAddress = txAddress.value,
|
txRadioAddress = txAddress.value,
|
||||||
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
||||||
txRadioName = txName.value,
|
txRadioName = txName.value,
|
||||||
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
||||||
baudRate = baudRate.intValue,
|
baudRate = baudRate.intValue,
|
||||||
splitMode = splitMode.value
|
splitMode = splitMode.value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
SharedDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.rc_settings_title),
|
title = stringResource(R.string.rc_settings_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||||
|
|
||||||
|
// Enable switch
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.rc_enable_switch))
|
Text(stringResource(R.string.rc_enable_switch))
|
||||||
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// Radio model — FlowRow so chips wrap on small screens
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.rc_radio_model),
|
text = stringResource(R.string.rc_radio_model),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Row(
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioModel.value == model,
|
selected = radioModel.value == model,
|
||||||
onClick = { radioModel.value = model },
|
onClick = { radioModel.value = model },
|
||||||
label = {
|
label = { Text(model, fontSize = 12.sp) },
|
||||||
Text(
|
enabled = enabled.value
|
||||||
text = compactRadioModelLabel(model),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = enabled.value,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
val isSplitModeAvailable = enabled.value && isIcom
|
// IC-705 split-mode toggle (only shown for IC-705)
|
||||||
val splitModeLabelColor = if (isSplitModeAvailable) {
|
if (isIcom) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
Row(
|
||||||
} else {
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
|
||||||
|
Text(
|
||||||
|
text = "Use VFO-A/B split on one IC-705 instead of two radios",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = splitMode.value,
|
||||||
|
onCheckedChange = { splitMode.value = it },
|
||||||
|
enabled = enabled.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Split mode (IC-705 only)",
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = splitModeLabelColor
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = splitMode.value,
|
|
||||||
onCheckedChange = { splitMode.value = it },
|
|
||||||
enabled = isSplitModeAvailable
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
|
|
||||||
Text("Radio devices", fontWeight = FontWeight.Medium)
|
// TX Radio (always shown; in split mode this is the single IC-705)
|
||||||
Row(
|
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
Text(txLabel, fontWeight = FontWeight.Medium)
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
CardButton(
|
|
||||||
onClick = { selectingFor.value = "tx" },
|
|
||||||
text = if (isSingleRadio) "Select Radio" else "Select TX",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
CardButton(
|
|
||||||
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
|
|
||||||
text = if (isSingleRadio) "RX = TX" else "Select RX",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (txAddress.value.isNotBlank()) {
|
if (txAddress.value.isNotBlank()) {
|
||||||
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
||||||
}
|
|
||||||
if (isSingleRadio && txAddress.value.isNotBlank()) {
|
|
||||||
Text("RX: same as TX", fontSize = 13.sp)
|
|
||||||
} else if (rxAddress.value.isNotBlank()) {
|
|
||||||
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
|
||||||
}
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { selectingFor.value = "tx" },
|
||||||
|
text = if (isSingleRadio) "Select Device" else "Select TX Device",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// RX Radio (hidden in split mode — the same radio handles both)
|
||||||
|
if (!isSingleRadio) {
|
||||||
|
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
|
||||||
|
if (rxAddress.value.isNotBlank()) {
|
||||||
|
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { selectingFor.value = "rx" },
|
||||||
|
text = "Select RX Device",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paired device picker (inline, shown while selecting)
|
||||||
if (selectingFor.value.isNotBlank()) {
|
if (selectingFor.value.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Paired Bluetooth Devices:",
|
text = "Paired Bluetooth Devices:",
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
if (pairedBluetoothDevices.isEmpty()) {
|
if (pairedDevices.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
||||||
fontSize = 13.sp
|
fontSize = 13.sp
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
pairedBluetoothDevices.forEach { (name, address) ->
|
pairedDevices.forEach { (name, address) ->
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
if (selectingFor.value == "tx") {
|
if (selectingFor.value == "tx") {
|
||||||
txAddress.value = address
|
txAddress.value = address
|
||||||
txName.value = name
|
txName.value = name
|
||||||
if (isSingleRadio) {
|
|
||||||
rxAddress.value = address
|
|
||||||
rxName.value = name
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
rxAddress.value = address
|
rxAddress.value = address
|
||||||
rxName.value = name
|
rxName.value = name
|
||||||
}
|
}
|
||||||
selectingFor.value = ""
|
selectingFor.value = ""
|
||||||
}
|
}
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(name, modifier = Modifier.weight(1f))
|
Text(name, modifier = Modifier.weight(1f))
|
||||||
@@ -679,26 +684,15 @@ fun RadioControlDialog(
|
|||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Baud rate — FlowRow so all chips fit on narrow screens
|
||||||
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
||||||
FlowRow(
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
maxItemsInEachRow = 6,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
baudRates.forEach { rate ->
|
baudRates.forEach { rate ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = rate == baudRate.intValue,
|
selected = rate == baudRate.intValue,
|
||||||
onClick = { baudRate.intValue = rate },
|
onClick = { baudRate.intValue = rate },
|
||||||
label = {
|
label = { Text(rate.toString(), fontSize = 12.sp) },
|
||||||
Text(
|
enabled = enabled.value
|
||||||
text = baudLabel(rate),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = enabled.value,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,20 +700,3 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun compactRadioModelLabel(model: String): String = when (model) {
|
|
||||||
RadioControlSettings.MODEL_YAESU_FT817 -> "FT-817/818"
|
|
||||||
RadioControlSettings.MODEL_YAESU_FT857 -> "FT-857/897"
|
|
||||||
RadioControlSettings.MODEL_ICOM_IC705 -> "IC-705"
|
|
||||||
else -> model
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun baudLabel(rate: Int): String = when (rate) {
|
|
||||||
4800 -> "4k"
|
|
||||||
9600 -> "9k"
|
|
||||||
19200 -> "19k"
|
|
||||||
38400 -> "38k"
|
|
||||||
57600 -> "57k"
|
|
||||||
115200 -> "115k"
|
|
||||||
else -> rate.toString()
|
|
||||||
}
|
|
||||||
+33
-107
@@ -25,12 +25,11 @@ 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.heightIn
|
||||||
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
|
||||||
@@ -40,7 +39,6 @@ import androidx.compose.material3.ElevatedCard
|
|||||||
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.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
|
||||||
@@ -193,7 +191,6 @@ 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)) }
|
||||||
)
|
)
|
||||||
@@ -222,7 +219,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
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)) {
|
||||||
@@ -244,7 +241,7 @@ 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 = { safeOpenUri(appUrl) },
|
onClick = { safeOpenUri(appUrl) },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
@@ -302,22 +299,8 @@ 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 { CardCredits() }
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
OtherCard(uiState.otherSettings, onAction)
|
|
||||||
CardCredits()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
|
|
||||||
) {
|
|
||||||
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
|
|
||||||
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +340,8 @@ private fun LocationCard(
|
|||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
||||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
@@ -496,83 +481,41 @@ 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 OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
ElevatedCard(
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_other_title),
|
text = stringResource(id = R.string.prefs_other_title),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
||||||
// Display preferences: UTC clock + night filter
|
onAction(SettingsAction.ToggleUtc(it))
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
|
||||||
onAction(SettingsAction.ToggleUtc(it))
|
|
||||||
}
|
|
||||||
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
// Radar behavior: sweep animation + sensor control
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
|
||||||
onAction(SettingsAction.ToggleSweep(it))
|
|
||||||
}
|
|
||||||
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
|
||||||
onAction(SettingsAction.ToggleSensor(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
// Data management (full width)
|
|
||||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||||
onAction(SettingsAction.ToggleUpdate(it))
|
onAction(SettingsAction.ToggleUpdate(it))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||||
// Compass calibration sliders at the bottom
|
onAction(SettingsAction.ToggleSweep(it))
|
||||||
CompassOffsetRow(
|
}
|
||||||
labelResId = R.string.prefs_other_compass_offset,
|
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
||||||
value = settings.radarCompassOffset,
|
onAction(SettingsAction.ToggleSensor(it))
|
||||||
range = -180f..180f
|
}
|
||||||
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
onAction(SettingsAction.ToggleNightMode(it))
|
||||||
CompassOffsetRow(
|
}
|
||||||
labelResId = R.string.prefs_other_compass_offset_elev,
|
|
||||||
value = settings.radarCompassOffsetElev,
|
|
||||||
range = -90f..90f
|
|
||||||
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CompassOffsetRow(
|
|
||||||
labelResId: Int,
|
|
||||||
value: Float,
|
|
||||||
range: ClosedFloatingPointRange<Float>,
|
|
||||||
onValueChange: (Float) -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = labelResId))
|
|
||||||
Text(text = "${value.toInt()}°")
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Slider(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
valueRange = range
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -585,21 +528,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)
|
||||||
@@ -632,14 +560,11 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CardCredits(modifier: Modifier = Modifier) {
|
private fun CardCredits(modifier: Modifier = Modifier) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
.fillMaxHeight()
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_outro_title),
|
text = stringResource(id = R.string.prefs_outro_title),
|
||||||
@@ -648,6 +573,8 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_outro_thanks)
|
text = stringResource(id = R.string.prefs_outro_thanks)
|
||||||
)
|
)
|
||||||
|
// 感谢列表与保修声明之间保留约两行间距 (用户要求)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_outro_license),
|
text = stringResource(id = R.string.prefs_outro_license),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
@@ -663,7 +590,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
@@ -678,7 +605,6 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(end = 6.dp)
|
.padding(end = 6.dp)
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
@@ -41,8 +41,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 pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -65,8 +64,6 @@ 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 SetRadarCompassOffsetElev(val value: Float) : SettingsAction
|
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+1
-6
@@ -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,8 +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
|
||||||
pairedBluetoothDevices = container.providePairedBluetoothDevices()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,8 +121,6 @@ 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.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
|
||||||
// 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)
|
||||||
@@ -208,7 +204,6 @@ class SettingsViewModel(
|
|||||||
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,370 +0,0 @@
|
|||||||
package com.rtbishop.look4sat.feature.status
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
|
||||||
import com.rtbishop.look4sat.feature.status.R as StatusR
|
|
||||||
import java.util.Calendar
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map AMSAT status text to Material3 colorScheme colors.
|
|
||||||
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun statusColorOf(statusText: String): Color {
|
|
||||||
return when {
|
|
||||||
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
|
|
||||||
MaterialTheme.colorScheme.tertiary // Active
|
|
||||||
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
|
|
||||||
MaterialTheme.colorScheme.tertiaryContainer // Telemetry
|
|
||||||
statusText.contains("Not Heard", ignoreCase = true) ->
|
|
||||||
Color(0xFFDC267F) // NotHeard pink (no semantic slot)
|
|
||||||
else ->
|
|
||||||
MaterialTheme.colorScheme.error // Conflict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SatStatusScreen(container: IMainContainer) {
|
|
||||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.statusBarsPadding()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
) {
|
|
||||||
// Top: legend + refresh
|
|
||||||
StatusHeader(
|
|
||||||
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
|
|
||||||
isRefreshing = uiState.isRefreshing,
|
|
||||||
onRefresh = { viewModel.refresh() }
|
|
||||||
)
|
|
||||||
LegendRow()
|
|
||||||
|
|
||||||
when {
|
|
||||||
uiState.isLoading -> {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uiState.error != null && uiState.statuses.isEmpty() -> {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.amsat_load_failed),
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
TextButton(onClick = { viewModel.refresh() }) {
|
|
||||||
Text(text = stringResource(id = R.string.amsat_retry))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// Header
|
|
||||||
HeaderRow(statuses = uiState.statuses)
|
|
||||||
HorizontalDivider(thickness = 1.dp)
|
|
||||||
// Row
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
items(uiState.statuses, key = { it.name }) { status ->
|
|
||||||
StatusRow(
|
|
||||||
status = status,
|
|
||||||
onClickDay = { day -> selectedDay = status to day }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedDay?.let { (status, day) ->
|
|
||||||
ReportDialog(
|
|
||||||
statusName = status.name,
|
|
||||||
day = day,
|
|
||||||
reports = uiState.reports,
|
|
||||||
onDismiss = { selectedDay = null }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Top: update time + refresh button (spinner while loading) */
|
|
||||||
@Composable
|
|
||||||
private fun StatusHeader(
|
|
||||||
fetchedAtUtcMs: Long,
|
|
||||||
isRefreshing: Boolean,
|
|
||||||
onRefresh: () -> Unit
|
|
||||||
) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
|
|
||||||
val angle by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 0f,
|
|
||||||
targetValue = 360f,
|
|
||||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
|
|
||||||
label = "angle"
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(34.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.clickable(onClick = onRefresh),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (isRefreshing) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
|
||||||
} else {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = StatusR.drawable.ic_refresh),
|
|
||||||
contentDescription = stringResource(id = R.string.amsat_refresh),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(18.dp)
|
|
||||||
.rotate(angle),
|
|
||||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legend (official four colors) */
|
|
||||||
@Composable
|
|
||||||
private fun LegendRow() {
|
|
||||||
val legend = listOf(
|
|
||||||
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary,
|
|
||||||
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
|
|
||||||
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
|
||||||
stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
legend.forEach { (label, color) ->
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.clip(RoundedCornerShape(2.dp))
|
|
||||||
.background(color)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(3.dp))
|
|
||||||
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Header: satellite name + 6 day dates */
|
|
||||||
@Composable
|
|
||||||
private fun HeaderRow(statuses: List<SatStatus>) {
|
|
||||||
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.amsat_name),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
|
||||||
)
|
|
||||||
dates.forEach { date ->
|
|
||||||
Text(
|
|
||||||
text = date,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.weight(0.8f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
|
|
||||||
@Composable
|
|
||||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
|
||||||
val noReportGray = 0xFFC0C0C0L
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 3.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = status.name,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
|
||||||
)
|
|
||||||
status.days.forEach { day ->
|
|
||||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
|
||||||
DayCell(
|
|
||||||
slot = slot,
|
|
||||||
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
|
|
||||||
onClick = { onClickDay(day) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Day block: newest reported status among the day's 12 slots; gray when none */
|
|
||||||
@Composable
|
|
||||||
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
|
||||||
val color = Color(slot.statusColor)
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.height(24.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.background(color)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (slot.count > 0) {
|
|
||||||
Text(
|
|
||||||
text = slot.count.toString(),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Report detail dialog (3 levels: callsign/date/time/grid) */
|
|
||||||
@Composable
|
|
||||||
private fun ReportDialog(
|
|
||||||
statusName: String,
|
|
||||||
day: SatDay,
|
|
||||||
reports: Map<String, SatReport>,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
|
||||||
val dayReports = day.slots.flatMap { it.reportIds }
|
|
||||||
.mapNotNull { reports[it] }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(text = "$statusName · ${day.dateLabel}") },
|
|
||||||
text = {
|
|
||||||
if (dayReports.isEmpty()) {
|
|
||||||
Text(stringResource(id = R.string.amsat_no_reports))
|
|
||||||
} else {
|
|
||||||
LazyColumn(modifier = Modifier.height(320.dp)) {
|
|
||||||
items(dayReports) { report ->
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(statusColorOf(report.statusText))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = report.statusText,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
|
||||||
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider(thickness = 0.5.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun formatFetchedAt(utcMs: Long): String {
|
|
||||||
val cal = Calendar.getInstance()
|
|
||||||
cal.timeInMillis = utcMs
|
|
||||||
return "%02d-%02d %02d:%02d".format(
|
|
||||||
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
|
||||||
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-94
@@ -1,94 +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.IMainContainer
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
data class SatStatusUiState(
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val isRefreshing: Boolean = false,
|
|
||||||
val statuses: List<SatStatus> = emptyList(),
|
|
||||||
val reports: Map<String, SatReport> = emptyMap(),
|
|
||||||
val fetchedAtUtcMs: Long = 0L,
|
|
||||||
val error: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class SatStatusViewModel(
|
|
||||||
private val container: IMainContainer
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
|
||||||
val uiState: StateFlow<SatStatusUiState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetch() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
|
||||||
try {
|
|
||||||
val page = container.amSatRepo.fetchStatus()
|
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
if (_uiState.value.isRefreshing) return
|
|
||||||
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val page = container.amSatRepo.fetchStatus()
|
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
|
||||||
}
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
SatStatusViewModel(container)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
|
||||||
</vector>
|
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
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
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "445"
|
appVersionCode = "448"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.5"
|
appVersionName = "4.4.8"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -11,6 +11,11 @@ minSdk = "24"
|
|||||||
jdkVersion = "21"
|
jdkVersion = "21"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
# Fork application ID: official app uses com.rtbishop.look4sat with a different
|
||||||
|
# signing cert. Installing a fork with the same applicationId would conflict
|
||||||
|
# with the official build (signature mismatch / cannot overwrite), so the fork
|
||||||
|
# ships under its own applicationId suffix.
|
||||||
|
applicationId = "com.rtbishop.look4sat.bg7nta"
|
||||||
|
|
||||||
android-gradle-plugin = "9.3.1"
|
android-gradle-plugin = "9.3.1"
|
||||||
|
|
||||||
@@ -23,7 +28,7 @@ compose-activity = "1.13.0"
|
|||||||
compose-lifecycle = "2.11.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.5"
|
compose-navigation3 = "1.1.5"
|
||||||
|
|
||||||
google-ksp = "2.3.10"
|
google-ksp = "2.3.8"
|
||||||
|
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
|
|||||||
+3
-2
@@ -22,9 +22,10 @@ include(
|
|||||||
)
|
)
|
||||||
include(
|
include(
|
||||||
":feature:map",
|
":feature:map",
|
||||||
|
":feature:mutual",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
|
":feature:roaming",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings",
|
":feature:settings"
|
||||||
":feature:status"
|
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user