mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 | ||
|
|
f585372594 | ||
|
|
4585332b1a | ||
|
|
6d2b0ced49 | ||
|
|
e86bc2c700 | ||
|
|
9a52fdfde5 | ||
|
|
07504a2a95 | ||
|
|
81525b6dd3 | ||
|
|
c7089ab314 | ||
|
|
77dfb7bb05 | ||
|
|
58fb688593 | ||
|
|
dc596b4348 | ||
|
|
f9b1a97a37 | ||
|
|
d700432829 | ||
|
|
b17ea2d918 | ||
|
|
976fdfd949 | ||
|
|
d8db258bd8 | ||
|
|
7b6fb5eb8f | ||
|
|
f4aef4f4f0 | ||
|
|
9147323db2 | ||
|
|
81d397a09d | ||
|
|
ec1a55c93d | ||
|
|
e5033cb2e9 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# GitHub Copilot Instructions
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then `CLAUDE.md`.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -4,3 +4,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "never"
|
||||||
@@ -16,13 +16,13 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v6
|
||||||
@@ -58,10 +58,11 @@ jobs:
|
|||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.4'
|
||||||
|
|
||||||
- name: Deploy Bundle to Google Play
|
- name: Deploy Bundle to Google Play
|
||||||
run: |
|
run: |
|
||||||
|
gem install multi_json
|
||||||
gem install fastlane --no-document
|
gem install fastlane --no-document
|
||||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Look4Sat AI Agent Instructions
|
||||||
|
|
||||||
|
This is the canonical project guide for all AI assistants working on Look4Sat.
|
||||||
|
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
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
|
||||||
|
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||||
|
no tracking, no network required after initial data download.
|
||||||
|
|
||||||
|
## Architecture & Design
|
||||||
|
|
||||||
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
|
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||||
|
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||||
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
|
**Clean Architecture layers:**
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|----------------------|---------------------------------------------------------------------|
|
||||||
|
| `app` | Entry point. Aggregates all modules |
|
||||||
|
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||||
|
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||||
|
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||||
|
| `feature:map` | OSMDroid map with ground tracks |
|
||||||
|
| `feature:passes` | Pass predictions and upcoming events |
|
||||||
|
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
||||||
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
|
**Feature isolation:**
|
||||||
|
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||||
|
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||||
|
|
||||||
|
## Build & Platform
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Debug build
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Release build (minified, shrunk resources)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
|
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
|
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||||
|
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||||
|
- **OkHttp** 5.x for data downloads
|
||||||
|
- **OSMDroid** for map rendering
|
||||||
|
- **Kotlin Serialization** for navigation args and parsing
|
||||||
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||||
|
|
||||||
|
## Data Formats & Migration
|
||||||
|
|
||||||
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||||
|
|
||||||
|
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||||
|
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||||
|
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||||
|
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||||
|
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||||
|
- Existing code already supports transparent source transition without feature changes
|
||||||
|
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||||
|
|
||||||
|
## Engineering Heuristics (Lazy = Efficient)
|
||||||
|
|
||||||
|
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||||
|
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||||
|
1. Does this need to be built now? (YAGNI)
|
||||||
|
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||||
|
3. Does Kotlin/Java stdlib already solve it?
|
||||||
|
4. Does the Android/platform API already solve it?
|
||||||
|
5. Does an already-installed dependency solve it?
|
||||||
|
6. Can this be simpler (including one-liner simple)?
|
||||||
|
7. Only then: write the minimum code that works.
|
||||||
|
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||||
|
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||||
|
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||||
|
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||||
|
- When two options are similar in size, choose the edge-case-correct one.
|
||||||
|
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||||
|
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||||
|
|
||||||
|
## Bug-Fix Policy
|
||||||
|
|
||||||
|
- Fix root cause, not just the reported symptom.
|
||||||
|
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||||
|
- The smallest correct diff wins only after behavior is understood.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
|
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||||
|
|
||||||
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
- Default to code changes only. Provide explanations in chat only.
|
||||||
|
- If documentation seems useful, ask first before creating files.
|
||||||
|
- Do NOT create any `.md` documentation files unless explicitly requested.
|
||||||
|
- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
|
||||||
|
- Prefer minimal diffs focused on requested implementation.
|
||||||
|
- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested.
|
||||||
@@ -1,110 +1,5 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## Project Overview
|
Read `AGENTS.md` first, then follow the instructions there.
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
|
||||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
|
||||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
|
||||||
data download.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
|
||||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
|
||||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
|
||||||
|
|
||||||
| ------------------------ | --------------------------------------------------------------------- |
|
|
||||||
| Module | Responsibility |
|
|
||||||
|--------------------------|-----------------------------------------------------------------------|
|
|
||||||
| `app` | Entry point. Aggregates all modules |
|
|
||||||
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
|
||||||
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
|
||||||
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
|
||||||
| `feature:map` | OSMDroid map with ground tracks |
|
|
||||||
| `feature:passes` | Pass predictions and upcoming events |
|
|
||||||
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
|
||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
|
||||||
| `feature:settings` | User preferences |
|
|
||||||
| ------------------------ | --------------------------------------------------------------------- |
|
|
||||||
|
|
||||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
|
||||||
|
|
||||||
## Build & Run
|
|
||||||
|
|
||||||
```shell
|
|
||||||
# Debug build
|
|
||||||
./gradlew assembleDebug
|
|
||||||
|
|
||||||
# Release build (minified, shrunk resources)
|
|
||||||
./gradlew assembleRelease
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
./gradlew test
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
|
||||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
|
||||||
|
|
||||||
## Key Libraries
|
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
|
||||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
|
||||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
|
||||||
- **OkHttp** 5.x for TLE downloads
|
|
||||||
- **OSMDroid** for map rendering
|
|
||||||
- **Kotlin Serialization** for navigation args and data parsing
|
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
|
||||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
|
||||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
|
||||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
|
||||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
|
||||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
|
||||||
|
|
||||||
## Data Formats & Migration
|
|
||||||
|
|
||||||
**TLE vs. OMM/CSV format:**
|
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
**Current implementation:**
|
|
||||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
|
||||||
- TLE data is downloaded from configured sources and stored in Room database
|
|
||||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
|
||||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
|
||||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
|
||||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
|
||||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
|
||||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
|
||||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
|
||||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
|
||||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
|
||||||
@@ -33,3 +33,13 @@ It is now and always will be completely ad-free and open-source.
|
|||||||
* Showing the satellite positional data, footprint and ground track on the map
|
* Showing the satellite positional data, footprint and ground track on the map
|
||||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
* Custom TLE satellite data import is available via Three Line Element .txt files
|
||||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -18,8 +19,8 @@
|
|||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="true">
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -29,6 +30,14 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- <intent-filter android:autoVerify="true">-->
|
||||||
|
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||||
|
<!-- <data android:scheme="https" />-->
|
||||||
|
<!-- <data android:host="github.com" />-->
|
||||||
|
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -32,6 +36,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
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.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -39,11 +44,14 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -62,6 +70,10 @@ 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.presentation.DeeplinkResolver
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.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
|
||||||
@@ -72,131 +84,174 @@ import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
|||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen() {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
LaunchedEffect(deeplink) {
|
||||||
|
deeplink?.let {
|
||||||
|
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||||
|
rootBackStack.add(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
|
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||||
|
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||||
|
NavDisplay(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
backStack = rootBackStack,
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { slideInTransition },
|
||||||
|
popTransitionSpec = { slideOutTransition },
|
||||||
|
predictivePopTransitionSpec = { slideOutTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
innerPadding.calculateTopPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { if (backStack.size > 1) backStack.removeAt(backStack.size - 1) }
|
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 slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
|
||||||
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, 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()
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
CompositionLocalProvider(
|
||||||
navigationSuiteItems = {
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
navItems.forEach { screen ->
|
low = otherSettings.lowElevation,
|
||||||
val isSelected = when (currentKey) {
|
high = otherSettings.highElevation
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
)
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
item(
|
|
||||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = {
|
|
||||||
if (isSelected) return@item
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
),
|
|
||||||
layoutType = when {
|
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Column {
|
NavigationSuiteScaffold(
|
||||||
NavDisplay(
|
navigationSuiteItems = {
|
||||||
backStack = backStack,
|
navItems.forEach { screen ->
|
||||||
modifier = Modifier.weight(1f),
|
val isSelected = when (currentKey) {
|
||||||
onBack = navigateBack,
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
transitionSpec = { fadeTransition },
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
popTransitionSpec = { fadeTransition },
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
is Screen.Map -> screen is Screen.Map
|
||||||
entryDecorators = listOf(
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
// Required for saving Compose state per entry
|
else -> false
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
|
||||||
// Required for ViewModel scoping per entry
|
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Satellites> {
|
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
item(
|
||||||
PassesDestination { catNum, aosTime ->
|
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
backStack.add(Screen.Radar)
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected) return@item
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
entry<Screen.Radar> {
|
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
entry<Screen.Map> {
|
|
||||||
MapDestination()
|
|
||||||
}
|
|
||||||
entry<Screen.Settings> {
|
|
||||||
SettingsDestination()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
// Radio tracking status banner
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
if (trackingState.isActive) {
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
),
|
||||||
val alpha by infiniteTransition.animateFloat(
|
layoutType = when {
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
animationSpec = infiniteRepeatable(
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
animation = tween(1000, easing = LinearEasing),
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
repeatMode = RepeatMode.Reverse
|
}
|
||||||
), label = "pulseAlpha"
|
) {
|
||||||
)
|
Column {
|
||||||
Row(
|
NavDisplay(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
backStack = backStack,
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f),
|
||||||
.fillMaxWidth()
|
onBack = navigateBack,
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
transitionSpec = { fadeTransition },
|
||||||
.clickable {
|
popTransitionSpec = { fadeTransition },
|
||||||
val pass = trackingState.currentPass
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
if (pass != null) {
|
entryDecorators = listOf(
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
// Required for saving Compose state per entry
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
// Required for ViewModel scoping per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
|
// navigateToRadar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
entry<Screen.Radar> {
|
||||||
) {
|
RadarDestination(navigateUp = navigateBack)
|
||||||
Box(
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.fillMaxWidth()
|
||||||
.clip(CircleShape)
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
.clickable {
|
||||||
)
|
val pass = trackingState.currentPass
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
if (pass != null) {
|
||||||
Text(
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
backStack.add(Screen.Radar)
|
||||||
fontSize = 13.sp,
|
}
|
||||||
fontWeight = FontWeight.Medium,
|
}
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
modifier = Modifier.weight(1f)
|
) {
|
||||||
)
|
Box(
|
||||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
modifier = Modifier
|
||||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
.size(8.dp)
|
||||||
Text(
|
.clip(CircleShape)
|
||||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
fontSize = 12.sp,
|
)
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
)
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
@@ -8,5 +8,6 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
description = "Cleans the build directory"
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ interface Look4SatDao {
|
|||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||||
|
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||||
|
"""
|
||||||
|
)
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
|
|||||||
+7
-4
@@ -17,6 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
object Ft817CatProtocol {
|
object Ft817CatProtocol {
|
||||||
|
|
||||||
const val CMD_SET_FREQ: Byte = 0x01
|
const val CMD_SET_FREQ: Byte = 0x01
|
||||||
@@ -50,7 +53,7 @@ object Ft817CatProtocol {
|
|||||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
val freq10Hz = frequencyHz / 10
|
val freq10Hz = frequencyHz / 10
|
||||||
val bcd = ByteArray(4)
|
val bcd = ByteArray(4)
|
||||||
val digits = String.format("%08d", freq10Hz)
|
val digits = String.format(Locale.US, "%08d", freq10Hz)
|
||||||
for (i in 0 until 4) {
|
for (i in 0 until 4) {
|
||||||
val high = digits[i * 2] - '0'
|
val high = digits[i * 2] - '0'
|
||||||
val low = digits[i * 2 + 1] - '0'
|
val low = digits[i * 2 + 1] - '0'
|
||||||
@@ -78,8 +81,8 @@ object Ft817CatProtocol {
|
|||||||
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||||
*/
|
*/
|
||||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
val tone01Hz = (toneHz * 10).toLong()
|
val tone01Hz = (toneHz * 10).roundToLong()
|
||||||
val digits = String.format("%04d", tone01Hz)
|
val digits = String.format(Locale.US, "%04d", tone01Hz)
|
||||||
val bcd = ByteArray(2)
|
val bcd = ByteArray(2)
|
||||||
for (i in 0 until 2) {
|
for (i in 0 until 2) {
|
||||||
val high = digits[i * 2] - '0'
|
val high = digits[i * 2] - '0'
|
||||||
@@ -95,7 +98,7 @@ object Ft817CatProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
|
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||||
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-30
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class Ft817Controller(
|
class Ft817Controller(
|
||||||
private val bluetoothManager: BluetoothManager,
|
private val bluetoothManager: BluetoothManager,
|
||||||
@@ -39,10 +40,12 @@ class Ft817Controller(
|
|||||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
private val ioMutex = Mutex()
|
private val ioMutex = Mutex()
|
||||||
private val commandDelayMs = 200L
|
private val commandDelayMs = 200L
|
||||||
|
private val maxAckReadFailures = 3
|
||||||
|
|
||||||
private var socket: BluetoothSocket? = null
|
private var socket: BluetoothSocket? = null
|
||||||
private var outputStream: OutputStream? = null
|
private var outputStream: OutputStream? = null
|
||||||
private var inputStream: InputStream? = null
|
private var inputStream: InputStream? = null
|
||||||
|
private var ackReadFailureCount = 0
|
||||||
|
|
||||||
override var isConnected: Boolean = false
|
override var isConnected: Boolean = false
|
||||||
private set
|
private set
|
||||||
@@ -57,6 +60,7 @@ class Ft817Controller(
|
|||||||
socket = btSocket
|
socket = btSocket
|
||||||
outputStream = btSocket.outputStream
|
outputStream = btSocket.outputStream
|
||||||
inputStream = btSocket.inputStream
|
inputStream = btSocket.inputStream
|
||||||
|
ackReadFailureCount = 0
|
||||||
isConnected = true
|
isConnected = true
|
||||||
Log.i(tag, "Connected to $deviceAddress")
|
Log.i(tag, "Connected to $deviceAddress")
|
||||||
true
|
true
|
||||||
@@ -79,6 +83,7 @@ class Ft817Controller(
|
|||||||
inputStream = null
|
inputStream = null
|
||||||
outputStream = null
|
outputStream = null
|
||||||
socket = null
|
socket = null
|
||||||
|
ackReadFailureCount = 0
|
||||||
isConnected = false
|
isConnected = false
|
||||||
Log.i(tag, "Disconnected from $deviceAddress")
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
}
|
}
|
||||||
@@ -112,8 +117,8 @@ class Ft817Controller(
|
|||||||
ioMutex.withLock {
|
ioMutex.withLock {
|
||||||
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||||
if (!sent) return@withContext null
|
if (!sent) return@withContext null
|
||||||
delay(commandDelayMs)
|
delay(commandDelayMs.milliseconds)
|
||||||
val response = readResponse(5) ?: return@withContext null
|
val response = readResponse() ?: return@withContext null
|
||||||
Ft817CatProtocol.parseReadResponse(response)
|
Ft817CatProtocol.parseReadResponse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,44 +132,76 @@ class Ft817Controller(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
outputStream?.write(bytes) ?: return false
|
try {
|
||||||
outputStream?.flush()
|
outputStream?.write(bytes) ?: return@withContext false
|
||||||
delay(commandDelayMs)
|
outputStream?.flush()
|
||||||
true
|
delay(commandDelayMs.milliseconds)
|
||||||
} catch (e: Exception) {
|
true
|
||||||
Log.e(tag, "Send error: ${e.message}")
|
} catch (e: Exception) {
|
||||||
isConnected = false
|
Log.e(tag, "Send error: ${e.message}")
|
||||||
false
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||||
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||||
if (!sendCommand(bytes)) return false
|
if (!sendCommand(bytes)) return false
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
val ack = inputStream?.read() ?: return false
|
try {
|
||||||
ack == 0x00
|
val ack = inputStream?.read() ?: run {
|
||||||
} catch (e: Exception) {
|
ackReadFailureCount = 0
|
||||||
Log.e(tag, "ACK read error: ${e.message}")
|
isConnected = false
|
||||||
true // command was sent, ACK read failed - continue anyway
|
return@withContext false
|
||||||
|
}
|
||||||
|
if (ack < 0) {
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
Log.i(tag, "ACK stream closed by remote device")
|
||||||
|
isConnected = false
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
ack == 0x00
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ackReadFailureCount += 1
|
||||||
|
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
|
||||||
|
if (ackReadFailureCount >= maxAckReadFailures) {
|
||||||
|
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true // Command was sent, treat transient ACK read failures as best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readResponse(length: Int): ByteArray? {
|
private suspend fun readResponse(): ByteArray? {
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
val buffer = ByteArray(length)
|
try {
|
||||||
var read = 0
|
val responseSize = 5
|
||||||
while (read < length) {
|
val buffer = ByteArray(responseSize)
|
||||||
val count = inputStream?.read(buffer, read, length - read) ?: return null
|
var read = 0
|
||||||
if (count < 0) return null
|
while (read < responseSize) {
|
||||||
read += count
|
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
|
||||||
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
if (count < 0) {
|
||||||
|
Log.i(tag, "Response stream closed by remote device")
|
||||||
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
read += count
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Read error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
null
|
||||||
}
|
}
|
||||||
buffer
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "Read error: ${e.message}")
|
|
||||||
isConnected = false
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icom IC-705 CI-V controller over Bluetooth SPP.
|
||||||
|
*
|
||||||
|
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
|
||||||
|
* level, …). A reply to any command we send may therefore be buried in
|
||||||
|
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
|
||||||
|
* entire accumulated buffer for the frame we expect rather than assuming
|
||||||
|
* the very next byte is the response.
|
||||||
|
*/
|
||||||
|
class Ic705Controller(
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val deviceAddress: String
|
||||||
|
) : IRadioController {
|
||||||
|
|
||||||
|
private val tag = "IC705"
|
||||||
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val ioMutex = Mutex()
|
||||||
|
|
||||||
|
/** Time budget (ms) to wait for a response amid broadcast noise. */
|
||||||
|
private val ACK_TIMEOUT_MS = 500L
|
||||||
|
/** Polling interval while draining the input buffer. */
|
||||||
|
private val POLL_INTERVAL_MS = 20L
|
||||||
|
/** Small pause after writing a command before reading the response. */
|
||||||
|
private val WRITE_SETTLE_MS = 50L
|
||||||
|
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
private var outputStream: OutputStream? = null
|
||||||
|
private var inputStream: InputStream? = null
|
||||||
|
|
||||||
|
override var isConnected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (isConnected) return@withContext true
|
||||||
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
try {
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
btSocket.connect()
|
||||||
|
socket = btSocket
|
||||||
|
outputStream = btSocket.outputStream
|
||||||
|
inputStream = btSocket.inputStream
|
||||||
|
isConnected = true
|
||||||
|
// Enter VFO mode — frequency/mode commands return FA if the radio
|
||||||
|
// is in memory-channel mode. Safe to send regardless of current state.
|
||||||
|
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
|
||||||
|
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
|
||||||
|
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Connect error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
inputStream?.close()
|
||||||
|
outputStream?.close()
|
||||||
|
socket?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Disconnect error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
inputStream = null
|
||||||
|
outputStream = null
|
||||||
|
socket = null
|
||||||
|
isConnected = false
|
||||||
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRadioController – standard operations ──────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
sendAndWaitAck(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
|
||||||
|
Log.w(tag, "setMode: unknown mode '$mode'")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
Log.d(tag, "setMode: $mode")
|
||||||
|
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setCtcssMode: $enabled")
|
||||||
|
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
|
||||||
|
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
|
||||||
|
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadFreqCommand()
|
||||||
|
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
|
||||||
|
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
|
||||||
|
IcomCivProtocol.parseFreqModePayload(payload).also {
|
||||||
|
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
|
||||||
|
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.w(tag, "pttOn: not used for IC-705")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.w(tag, "pttOff: not used for IC-705")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRadioController – IC-705 extended operations ───────────────────────
|
||||||
|
|
||||||
|
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
|
||||||
|
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
|
||||||
|
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
|
||||||
|
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
|
||||||
|
else IcomCivProtocol.buildSelectVfoBCommand()
|
||||||
|
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
|
||||||
|
*/
|
||||||
|
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
|
||||||
|
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
|
||||||
|
* In split mode the radio automatically switches active VFO on PTT, so
|
||||||
|
* always writing to the active VFO is the correct strategy.
|
||||||
|
*/
|
||||||
|
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
|
||||||
|
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
|
||||||
|
*/
|
||||||
|
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
*/
|
||||||
|
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
|
||||||
|
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||||
|
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||||
|
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||||
|
if (payload.size < 6) {
|
||||||
|
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val freqBcd = payload.sliceArray(1..5)
|
||||||
|
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||||
|
Log.d(tag, "readWorkingFreq: ${freq}Hz")
|
||||||
|
freq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
*/
|
||||||
|
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
|
||||||
|
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||||
|
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||||
|
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||||
|
if (payload.size < 6) {
|
||||||
|
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val freqBcd = payload.sliceArray(1..5)
|
||||||
|
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||||
|
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
|
||||||
|
freq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal I/O helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [cmd] to the radio and drain the input stream for up to
|
||||||
|
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
|
||||||
|
*/
|
||||||
|
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
|
||||||
|
if (!write(cmd)) return false
|
||||||
|
delay(WRITE_SETTLE_MS)
|
||||||
|
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||||
|
val ok = IcomCivProtocol.containsAck(buf)
|
||||||
|
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [cmd] to the radio and drain the input stream for up to
|
||||||
|
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
|
||||||
|
* Returns the payload bytes of that frame, or null on timeout/error.
|
||||||
|
*/
|
||||||
|
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
|
||||||
|
if (!write(cmd)) return null
|
||||||
|
delay(WRITE_SETTLE_MS)
|
||||||
|
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||||
|
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
|
||||||
|
if (response == null) {
|
||||||
|
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
|
||||||
|
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||||
|
}
|
||||||
|
return response?.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
|
||||||
|
* Exits early as soon as a complete CI-V frame addressed to us is present
|
||||||
|
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
|
||||||
|
* timeout on responses that already arrived.
|
||||||
|
*/
|
||||||
|
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
|
||||||
|
val result = mutableListOf<Byte>()
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
val stream = inputStream ?: return ByteArray(0)
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
try {
|
||||||
|
val available = stream.available()
|
||||||
|
if (available > 0) {
|
||||||
|
val chunk = ByteArray(available)
|
||||||
|
val read = stream.read(chunk)
|
||||||
|
if (read > 0) {
|
||||||
|
result.addAll(chunk.take(read))
|
||||||
|
// Exit early once we have a complete frame for us
|
||||||
|
if (hasCompleteFrameForUs(result)) break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Drain error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if [buf] contains a complete CI-V frame addressed to the
|
||||||
|
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
|
||||||
|
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
|
||||||
|
* always the frame terminator.
|
||||||
|
*/
|
||||||
|
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 4) {
|
||||||
|
if (buf[i] == IcomCivProtocol.PREAMBLE &&
|
||||||
|
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
|
||||||
|
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
|
||||||
|
buf[i + 3] == IcomCivProtocol.ADDR_IC705
|
||||||
|
) {
|
||||||
|
for (k in i + 4 until buf.size) {
|
||||||
|
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
|
||||||
|
}
|
||||||
|
return false // header found but no FD yet
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(bytes: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
outputStream?.write(bytes)
|
||||||
|
outputStream?.flush()
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Write error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icom CI-V protocol encoder/decoder for the IC-705.
|
||||||
|
*
|
||||||
|
* Frame structure:
|
||||||
|
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
|
||||||
|
*
|
||||||
|
* IC-705 default CI-V address : 0xA4
|
||||||
|
* Controller (us) address : 0xE0
|
||||||
|
*/
|
||||||
|
object IcomCivProtocol {
|
||||||
|
|
||||||
|
// ── Framing constants ──────────────────────────────────────────────────
|
||||||
|
const val PREAMBLE: Byte = 0xFE.toByte()
|
||||||
|
const val END_OF_MSG: Byte = 0xFD.toByte()
|
||||||
|
const val ACK_OK: Byte = 0xFB.toByte()
|
||||||
|
const val ACK_NG: Byte = 0xFA.toByte()
|
||||||
|
|
||||||
|
// ── Address constants ──────────────────────────────────────────────────
|
||||||
|
/** Default CI-V address of the IC-705. */
|
||||||
|
const val ADDR_IC705: Byte = 0xA4.toByte()
|
||||||
|
/** Default CI-V address of the controller (us). */
|
||||||
|
const val ADDR_CTRL: Byte = 0xE0.toByte()
|
||||||
|
|
||||||
|
// ── Command bytes ──────────────────────────────────────────────────────
|
||||||
|
/** Read operating frequency (main VFO). */
|
||||||
|
const val CMD_READ_FREQ: Byte = 0x03
|
||||||
|
/** Set operating frequency (main VFO). */
|
||||||
|
const val CMD_SET_FREQ: Byte = 0x05
|
||||||
|
/** Set operating mode. */
|
||||||
|
const val CMD_SET_MODE: Byte = 0x06
|
||||||
|
/** Select VFO / memory. */
|
||||||
|
const val CMD_SELECT_VFO: Byte = 0x07
|
||||||
|
/**
|
||||||
|
* Select operating mode (VFO vs memory-channel).
|
||||||
|
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
|
||||||
|
* memory-channel mode — frequency/mode commands return FA until it is.
|
||||||
|
*/
|
||||||
|
const val CMD_SELECT_OP_MODE: Byte = 0x08
|
||||||
|
/** Set repeater duplex / SPLIT. */
|
||||||
|
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
|
||||||
|
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
|
||||||
|
const val CMD_BAND_SELECT: Byte = 0x1A
|
||||||
|
/** Read/write CTCSS tone frequency. */
|
||||||
|
const val CMD_CTCSS_TONE: Byte = 0x1B
|
||||||
|
/** Read/write misc settings (used for enabling CTCSS encode). */
|
||||||
|
const val CMD_MISC_SETTING: Byte = 0x16
|
||||||
|
/** Read/write selected-VFO frequency (cmd 0x25). */
|
||||||
|
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
|
||||||
|
|
||||||
|
// ── Sub-command bytes ──────────────────────────────────────────────────
|
||||||
|
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
|
||||||
|
const val SUB_VFO_A: Byte = 0x00
|
||||||
|
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
|
||||||
|
const val SUB_VFO_B: Byte = 0x01
|
||||||
|
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
|
||||||
|
const val SUB_SPLIT_OFF: Byte = 0x00
|
||||||
|
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
|
||||||
|
const val SUB_SPLIT_ON: Byte = 0x01
|
||||||
|
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
|
||||||
|
const val SUB_SELECTED_VFO: Byte = 0x00
|
||||||
|
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
|
||||||
|
const val SUB_UNSELECTED_VFO: Byte = 0x01
|
||||||
|
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
|
||||||
|
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
|
||||||
|
|
||||||
|
// ── Mode bytes ────────────────────────────────────────────────────────
|
||||||
|
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
|
||||||
|
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||||
|
"LSB" to 0x00,
|
||||||
|
"USB" to 0x01,
|
||||||
|
"AM" to 0x02,
|
||||||
|
"CW" to 0x03,
|
||||||
|
"RTTY" to 0x04,
|
||||||
|
"FM" to 0x05,
|
||||||
|
"WFM" to 0x06,
|
||||||
|
"CW-R" to 0x07,
|
||||||
|
"RTTY-R" to 0x08,
|
||||||
|
"DV" to 0x12,
|
||||||
|
"AFSK" to 0x05 // AFSK uses FM modulation
|
||||||
|
)
|
||||||
|
|
||||||
|
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||||
|
|
||||||
|
// ── Frequency BCD encoding ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
|
||||||
|
*
|
||||||
|
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
|
||||||
|
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
|
||||||
|
* [00, 00, 50, 45, 01]
|
||||||
|
*/
|
||||||
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
|
val digits = String.format(Locale.US, "%010d", frequencyHz)
|
||||||
|
val bcd = ByteArray(5)
|
||||||
|
for (i in 0 until 5) {
|
||||||
|
// digits are MSB first; we want pair index 0 = LSB pair
|
||||||
|
val pairIndex = 4 - i
|
||||||
|
val high = digits[pairIndex * 2] - '0'
|
||||||
|
val low = digits[pairIndex * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
|
||||||
|
*/
|
||||||
|
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||||
|
// Build digit string MSB→LSB by reversing the byte order
|
||||||
|
var freqHz = 0L
|
||||||
|
for (i in 4 downTo 0) {
|
||||||
|
val b = bcd[i].toInt() and 0xFF
|
||||||
|
val high = b shr 4
|
||||||
|
val low = b and 0x0F
|
||||||
|
freqHz = freqHz * 100 + high * 10 + low
|
||||||
|
}
|
||||||
|
return freqHz
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
|
||||||
|
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
|
||||||
|
*/
|
||||||
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
|
val tone01 = (toneHz * 10).toLong()
|
||||||
|
val digits = String.format(Locale.US, "%04d", tone01)
|
||||||
|
return byteArrayOf(
|
||||||
|
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
|
||||||
|
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
|
||||||
|
private fun frame(vararg payload: Byte): ByteArray {
|
||||||
|
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
|
||||||
|
payload +
|
||||||
|
byteArrayOf(END_OF_MSG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set operating frequency via CMD 0x05 (main VFO). */
|
||||||
|
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
|
||||||
|
* This updates whichever VFO is currently active (RX or TX after split).
|
||||||
|
*/
|
||||||
|
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
|
||||||
|
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
|
||||||
|
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
|
||||||
|
* Use this to update the TX VFO when PTT is on.
|
||||||
|
*/
|
||||||
|
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read operating frequency (CMD 0x03). */
|
||||||
|
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
|
||||||
|
|
||||||
|
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
|
||||||
|
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
|
||||||
|
|
||||||
|
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
|
||||||
|
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select band via CMD 0x1A sub 0x00.
|
||||||
|
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
|
||||||
|
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
|
||||||
|
*/
|
||||||
|
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
|
||||||
|
val code = bandCodeForFrequency(frequencyHz) ?: return null
|
||||||
|
return frame(CMD_BAND_SELECT, 0x00, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a frequency in Hz to the IC-705 band stacking register code.
|
||||||
|
* Codes are BCD (band number in decimal expressed as hex nibbles).
|
||||||
|
*/
|
||||||
|
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
|
||||||
|
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
|
||||||
|
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
|
||||||
|
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
|
||||||
|
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
|
||||||
|
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
|
||||||
|
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
|
||||||
|
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
|
||||||
|
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
|
||||||
|
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
|
||||||
|
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
|
||||||
|
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
|
||||||
|
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
|
||||||
|
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
|
||||||
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
|
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||||
|
return frame(CMD_SET_MODE, modeByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select VFO-A (CMD 0x07 sub 0x00). */
|
||||||
|
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
|
||||||
|
|
||||||
|
/** Select VFO-B (CMD 0x07 sub 0x01). */
|
||||||
|
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter VFO operating mode (CMD 0x08 sub 0x00).
|
||||||
|
* Sent after connect — if the radio is in memory-channel mode frequency
|
||||||
|
* and mode commands return FA until this is issued.
|
||||||
|
*/
|
||||||
|
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
|
||||||
|
|
||||||
|
/** Enable or disable SPLIT mode (CMD 0x0F). */
|
||||||
|
fun buildSplitModeCommand(enable: Boolean): ByteArray {
|
||||||
|
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
|
||||||
|
return frame(CMD_DUPLEX_SPLIT, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
|
||||||
|
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
|
||||||
|
*/
|
||||||
|
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||||
|
val value: Byte = if (enabled) 0x01 else 0x00
|
||||||
|
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
|
||||||
|
*/
|
||||||
|
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||||
|
val bcd = encodeCtcssToneBcd(toneHz)
|
||||||
|
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response parsing ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and parse a complete CI-V response frame from a buffer.
|
||||||
|
*
|
||||||
|
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
|
||||||
|
* complete frame was found. The search is tolerant of interleaved
|
||||||
|
* broadcast traffic.
|
||||||
|
*
|
||||||
|
* @param buf bytes accumulated from the radio
|
||||||
|
* @param expectCmd the command byte we are looking for in the reply, or
|
||||||
|
* null to accept any command response from the radio
|
||||||
|
*/
|
||||||
|
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 5) {
|
||||||
|
// Look for FE FE preamble
|
||||||
|
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||||
|
val dest = buf[i + 2]
|
||||||
|
val src = buf[i + 3]
|
||||||
|
val cmd = buf[i + 4]
|
||||||
|
// We only care about frames addressed to us from the radio
|
||||||
|
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||||
|
// Find the terminating FD
|
||||||
|
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||||
|
if (fdIdx < 0) break // incomplete frame, wait for more data
|
||||||
|
val payload = buf.copyOfRange(i + 5, fdIdx)
|
||||||
|
if (expectCmd == null || cmd == expectCmd) {
|
||||||
|
return ParsedResponse(cmd, payload, fdIdx + 1)
|
||||||
|
}
|
||||||
|
i = fdIdx + 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
|
||||||
|
for (k in startIndex until size) if (this[k] == b) return k
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a buffer contains an OK acknowledgement (FB FD) from
|
||||||
|
* the radio. Tolerates broadcast noise before the ACK.
|
||||||
|
*/
|
||||||
|
fun containsAck(buf: ByteArray): Boolean {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 5) {
|
||||||
|
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||||
|
val dest = buf[i + 2]
|
||||||
|
val src = buf[i + 3]
|
||||||
|
val cmd = buf[i + 4]
|
||||||
|
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||||
|
// Skip to FD
|
||||||
|
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||||
|
if (fdIdx < 0) break
|
||||||
|
if (cmd == ACK_OK) return true
|
||||||
|
if (cmd == ACK_NG) return false
|
||||||
|
i = fdIdx + 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||||
|
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||||
|
*/
|
||||||
|
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||||
|
if (payload.size < 6) return null
|
||||||
|
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||||
|
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||||
|
return freqHz to mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hex dump of bytes, useful for debug logging. */
|
||||||
|
fun toHex(bytes: ByteArray): String =
|
||||||
|
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
|
||||||
|
|
||||||
|
data class ParsedResponse(
|
||||||
|
val cmd: Byte,
|
||||||
|
val payload: ByteArray,
|
||||||
|
/** Index in the source buffer immediately after the FD terminator. */
|
||||||
|
val nextOffset: Int
|
||||||
|
)
|
||||||
|
}
|
||||||
+403
-204
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
@@ -29,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -44,6 +47,8 @@ class RadioTrackingService(
|
|||||||
) : IRadioTrackingService {
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
private val tag = "RadioTracking"
|
private val tag = "RadioTracking"
|
||||||
|
/** Delay between each step of the split-mode init sequence (ms). */
|
||||||
|
private val INIT_STEP_DELAY_MS = 200L
|
||||||
private val _state = MutableStateFlow(RadioTrackingState())
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
override val state: StateFlow<RadioTrackingState> = _state
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
@@ -51,227 +56,427 @@ class RadioTrackingService(
|
|||||||
private var rxController: IRadioController? = null
|
private var rxController: IRadioController? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override suspend fun connectRadios() {
|
override suspend fun connectRadios() {
|
||||||
// Disconnect old controllers if any
|
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
|
|
||||||
// Read current addresses from settings
|
|
||||||
val rcSettings = settingsRepo.radioControlSettings.value
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
val txAddr = rcSettings.txRadioAddress
|
val txAddr = rcSettings.txRadioAddress
|
||||||
val rxAddr = rcSettings.rxRadioAddress
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
|
|
||||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
if (isSplit) {
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
// Single-radio split mode: only TX slot is used
|
||||||
return
|
if (txAddr.isBlank()) {
|
||||||
}
|
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
||||||
|
return
|
||||||
val tx = Ft817Controller(bluetoothManager, txAddr)
|
}
|
||||||
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
val tx = makeController(isIcom, txAddr)
|
||||||
txController = tx
|
txController = tx
|
||||||
rxController = rx
|
rxController = null
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
_state.update { it.copy(errorMessage = null) }
|
val txOk = tx.connect()
|
||||||
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
_state.update {
|
||||||
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
it.copy(
|
||||||
_state.update {
|
txConnected = txOk,
|
||||||
it.copy(
|
rxConnected = false,
|
||||||
txConnected = txOk,
|
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
|
||||||
rxConnected = rxOk,
|
)
|
||||||
errorMessage = when {
|
}
|
||||||
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
|
||||||
!txOk -> "Could not connect to TX radio ($txAddr)"
|
} else {
|
||||||
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
else -> null
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
}
|
return
|
||||||
)
|
}
|
||||||
|
val tx = makeController(isIcom, txAddr)
|
||||||
|
val rx = makeController(isIcom, rxAddr)
|
||||||
|
txController = tx
|
||||||
|
rxController = rx
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txOk,
|
||||||
|
rxConnected = rxOk,
|
||||||
|
errorMessage = when {
|
||||||
|
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
||||||
|
!txOk -> "Could not connect to TX radio ($txAddr)"
|
||||||
|
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun makeController(isIcom: Boolean, address: String): IRadioController =
|
||||||
|
if (isIcom) Ic705Controller(bluetoothManager, address)
|
||||||
|
else Ft817Controller(bluetoothManager, address)
|
||||||
|
|
||||||
override suspend fun disconnectRadios() {
|
override suspend fun disconnectRadios() {
|
||||||
stopTracking()
|
stopTracking()
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
txController = null
|
txController = null
|
||||||
rxController = null
|
rxController = null
|
||||||
_state.update {
|
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
||||||
it.copy(
|
|
||||||
txConnected = false,
|
|
||||||
rxConnected = false,
|
|
||||||
isActive = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isActive = true,
|
isActive = true,
|
||||||
currentPass = pass,
|
currentPass = pass,
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txBaseFreqHz
|
txBaseFrequencyHz = txBaseFreqHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = appScope.launch {
|
|
||||||
// Set modes on both radios at tracking start
|
|
||||||
val tx = txController
|
|
||||||
val rx = rxController
|
|
||||||
val txMode = transponder.uplinkMode
|
|
||||||
val rxMode = transponder.downlinkMode
|
|
||||||
?: transponder.uplinkMode?.let {
|
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
|
||||||
}
|
|
||||||
if (tx != null && tx.isConnected && txMode != null) {
|
|
||||||
tx.setMode(txMode)
|
|
||||||
Log.i(tag, "TX mode set to $txMode")
|
|
||||||
}
|
|
||||||
if (rx != null && rx.isConnected && rxMode != null) {
|
|
||||||
rx.setMode(rxMode)
|
|
||||||
Log.i(tag, "RX mode set to $rxMode")
|
|
||||||
}
|
|
||||||
// Set CTCSS if FM
|
|
||||||
if (txMode?.uppercase() == "FM") {
|
|
||||||
_state.value.ctcssTone?.let { tone ->
|
|
||||||
tx?.setCtcssTone(tone)
|
|
||||||
tx?.setCtcssMode(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
|
||||||
|
|
||||||
var lastSetTxFreq = 0.0
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
var lastSetRxFreq = 0.0
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
var lastReadFreq = 0L
|
|
||||||
var stableCount = 0
|
|
||||||
|
|
||||||
while (isActive) {
|
if (isSplit) {
|
||||||
val currentState = _state.value
|
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
|
||||||
if (!currentState.isActive) break
|
} else {
|
||||||
|
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
|
||||||
val satPass = currentState.currentPass ?: break
|
|
||||||
val xpdr = currentState.selectedTransponder ?: break
|
|
||||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
|
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
|
||||||
val tx = txController
|
|
||||||
val rx = rxController
|
|
||||||
val hasUplink = txBaseFreq != null
|
|
||||||
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
|
||||||
val v = pos.distanceRate * 1000.0
|
|
||||||
|
|
||||||
if (tuningRadio.isNotEmpty()) {
|
|
||||||
// --- User is tuning: keep reading, wait for stabilization ---
|
|
||||||
val radio = if (tuningRadio == "tx") tx else rx
|
|
||||||
if (radio != null && radio.isConnected) {
|
|
||||||
val readResult = radio.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (freq, _) = readResult
|
|
||||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
|
||||||
stableCount++
|
|
||||||
} else {
|
|
||||||
stableCount = 0
|
|
||||||
lastReadFreq = freq
|
|
||||||
}
|
|
||||||
// Stable for 2 reads → user stopped turning
|
|
||||||
if (stableCount >= 2) {
|
|
||||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
|
||||||
val newBase = (freq.toDouble() * c / (c + v)).toLong()
|
|
||||||
if (newBase > 0) {
|
|
||||||
txBaseFreq = newBase
|
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
|
||||||
Log.i(tag, "TX tuning done → base=$newBase")
|
|
||||||
}
|
|
||||||
} else if (tuningRadio == "rx") {
|
|
||||||
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
|
|
||||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
|
||||||
if (newTxBase != null && newTxBase > 0) {
|
|
||||||
txBaseFreq = newTxBase
|
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
|
||||||
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tuningRadio = ""
|
|
||||||
stableCount = 0
|
|
||||||
lastSetTxFreq = 0.0
|
|
||||||
lastSetRxFreq = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// --- Normal tracking: read, detect changes, command ---
|
|
||||||
|
|
||||||
// TX dial feedback
|
|
||||||
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
|
||||||
val readResult = tx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualTxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "tx"
|
|
||||||
lastReadFreq = actualTxFreq
|
|
||||||
stableCount = 0
|
|
||||||
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RX dial feedback (only if TX not tuning)
|
|
||||||
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
|
|
||||||
val readResult = rx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualRxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "rx"
|
|
||||||
lastReadFreq = actualRxFreq
|
|
||||||
stableCount = 0
|
|
||||||
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute Doppler-corrected frequencies
|
|
||||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
|
||||||
val rxBaseFreq = if (txBaseFreq != null) {
|
|
||||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
|
||||||
} else {
|
|
||||||
xpdr.downlinkLow
|
|
||||||
}
|
|
||||||
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
|
||||||
|
|
||||||
// Command radios (only when not tuning)
|
|
||||||
if (tuningRadio.isEmpty()) {
|
|
||||||
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
|
||||||
tx.setFrequency(txRadioFreq)
|
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
|
||||||
}
|
|
||||||
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
|
||||||
rx.setFrequency(rxRadioFreq)
|
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
txConnected = tx?.isConnected ?: false,
|
|
||||||
rxConnected = rx?.isConnected ?: false,
|
|
||||||
txFrequencyHz = txRadioFreq,
|
|
||||||
rxFrequencyHz = rxRadioFreq,
|
|
||||||
azimuth = Math.toDegrees(pos.azimuth),
|
|
||||||
elevation = Math.toDegrees(pos.elevation),
|
|
||||||
distance = pos.distance
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
|
||||||
|
// Initial setup: set band/mode/CTCSS on both radios
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
|
Log.d(tag, "Setting TX mode: $txMode")
|
||||||
|
tx.setMode(txMode)
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
|
Log.d(tag, "Setting RX mode: $rxMode")
|
||||||
|
rx.setMode(rxMode)
|
||||||
|
}
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
Log.d(tag, "Setting CTCSS: ${tone}Hz")
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = ""
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val currentState = _state.value
|
||||||
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
|
val satPass = currentState.currentPass ?: break
|
||||||
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val txNow = txController
|
||||||
|
val rxNow = rxController
|
||||||
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
val radio = if (tuningRadio == "tx") txNow else rxNow
|
||||||
|
if (radio != null && radio.isConnected) {
|
||||||
|
val read = radio.readFrequencyAndMode()
|
||||||
|
if (read != null) {
|
||||||
|
val (freq, _) = read
|
||||||
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
||||||
|
else { stableCount = 0; lastReadFreq = freq }
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
|
||||||
|
val read = txNow.readFrequencyAndMode()
|
||||||
|
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = read.first
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
|
||||||
|
val read = rxNow.readFrequencyAndMode()
|
||||||
|
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = read.first
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else xpdr.downlinkLow
|
||||||
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
if (tuningRadio.isEmpty()) {
|
||||||
|
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||||
|
txNow.setFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||||
|
rxNow.setFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txNow?.isConnected ?: false,
|
||||||
|
rxConnected = rxNow?.isConnected ?: false,
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IC-705 split-radio tracking ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
|
val radio = txController ?: return
|
||||||
|
if (!radio.isConnected) return
|
||||||
|
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute nominal base frequencies
|
||||||
|
val txCenter = when {
|
||||||
|
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||||
|
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||||
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val rxNominal = if (txCenter != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
|
} else transponder.downlinkLow
|
||||||
|
|
||||||
|
val txBase = initialTxBaseFreqHz ?: txCenter
|
||||||
|
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
|
// ── Initial setup sequence ──────────────────────────────────────────
|
||||||
|
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
|
||||||
|
// ACK from each command gates the next — no fixed delays needed.
|
||||||
|
|
||||||
|
// VFO-A = RX (downlink)
|
||||||
|
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
|
||||||
|
radio.setVfo(vfoA = true)
|
||||||
|
if (rxNominal != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
|
||||||
|
radio.setBand(rxNominal)
|
||||||
|
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
|
||||||
|
radio.setFrequency(rxNominal)
|
||||||
|
}
|
||||||
|
if (rxMode != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-A mode=$rxMode")
|
||||||
|
radio.setMode(rxMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VFO-B = TX (uplink)
|
||||||
|
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
|
||||||
|
radio.setVfo(vfoA = false)
|
||||||
|
if (txBase != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
|
||||||
|
radio.setBand(txBase)
|
||||||
|
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
|
||||||
|
radio.setFrequency(txBase)
|
||||||
|
}
|
||||||
|
if (txMode != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-B mode=$txMode")
|
||||||
|
radio.setMode(txMode)
|
||||||
|
}
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
val tone = _state.value.ctcssTone
|
||||||
|
if (tone != null) {
|
||||||
|
Log.d(tag, "Split init: CTCSS=${tone}Hz")
|
||||||
|
radio.setCtcssTone(tone)
|
||||||
|
radio.setCtcssMode(true)
|
||||||
|
} else {
|
||||||
|
radio.setCtcssMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable SPLIT on VFO-A (return display to RX VFO first)
|
||||||
|
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
|
||||||
|
radio.setVfo(vfoA = true)
|
||||||
|
radio.setSplitMode(enabled = true)
|
||||||
|
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
|
||||||
|
Log.i(tag, "IC-705 split init done — entering tracking loop")
|
||||||
|
|
||||||
|
// ── Tracking loop with tuning detection ─────────────────────────────
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val currentState = _state.value
|
||||||
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
|
val satPass = currentState.currentPass ?: break
|
||||||
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
// User is tuning — wait for frequency to stabilize
|
||||||
|
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
|
||||||
|
if (readFreq != null) {
|
||||||
|
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
|
||||||
|
else { stableCount = 0; lastReadFreq = readFreq }
|
||||||
|
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
// Frequency stable — reverse-calculate base frequency
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "Split TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
|
||||||
|
val readTx = radio.readTxVfoFrequency()
|
||||||
|
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = readTx
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
|
||||||
|
val readRx = radio.readWorkingFrequency()
|
||||||
|
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = readRx
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine Doppler-corrected frequencies
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseCalc = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else xpdr.downlinkLow
|
||||||
|
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
if (radio.isConnected && tuningRadio.isEmpty()) {
|
||||||
|
// Update both VFOs every cycle — no PTT polling needed.
|
||||||
|
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||||
|
if (rxRadioFreq != null) {
|
||||||
|
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||||
|
radio.setWorkingFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (txRadioFreq != null) {
|
||||||
|
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||||
|
radio.setTxVfoFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = radio.isConnected,
|
||||||
|
rxConnected = false, // single radio
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Other IRadioTrackingService methods ─────────────────────────────────
|
||||||
|
|
||||||
override fun stopTracking() {
|
override fun stopTracking() {
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
@@ -288,7 +493,6 @@ class RadioTrackingService(
|
|||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
rxMode?.let { rx?.setMode(it) }
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
@@ -302,21 +506,17 @@ class RadioTrackingService(
|
|||||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
// Show nominal frequencies immediately
|
|
||||||
val rxNominal = if (txCenter != null) {
|
val rxNominal = if (txCenter != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
} else {
|
} else transponder.downlinkLow
|
||||||
// Downlink-only transponder (beacon etc.) - use downlink directly
|
|
||||||
transponder.downlinkLow
|
|
||||||
}
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txCenter,
|
txBaseFrequencyHz = txCenter,
|
||||||
txFrequencyHz = txCenter,
|
txFrequencyHz = txCenter,
|
||||||
rxFrequencyHz = rxNominal,
|
rxFrequencyHz = rxNominal,
|
||||||
txMode = transponder.uplinkMode,
|
txMode = transponder.uplinkMode,
|
||||||
rxMode = transponder.downlinkMode
|
rxMode = transponder.downlinkMode
|
||||||
?: transponder.uplinkMode?.let { m ->
|
?: transponder.uplinkMode?.let { m ->
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||||
}
|
}
|
||||||
@@ -353,5 +553,4 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+28
-11
@@ -19,14 +19,14 @@ package com.rtbishop.look4sat.core.data.injection
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.Sensor
|
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
@@ -40,6 +40,7 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
@@ -107,22 +108,38 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun provideTxRadioController(): IRadioController {
|
override fun provideTxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.txRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideRxRadioController(): IRadioController {
|
override fun provideRxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.rxRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
return SensorsRepo(manager, displayManager)
|
||||||
return SensorsRepo(manager,sensor,window)
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
+36
-12
@@ -42,21 +42,26 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
private val customSourceType = "Other"
|
private val customSourceType = "Other"
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = dataParser.parseTLEStream(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
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertRadios(transceivers)
|
localSource.insertRadios(transceivers)
|
||||||
|
importedCount = transceivers.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
@@ -72,12 +77,9 @@ class DatabaseRepo(
|
|||||||
// 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 and associate with types
|
// parse fetched data concurrently
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
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()
|
||||||
@@ -94,9 +96,31 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
|
val bufferedStream = stream.buffered()
|
||||||
else -> dataParser.parseTLEStream(stream)
|
return when {
|
||||||
|
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
||||||
|
else -> dataParser.parseTLEStream(bufferedStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasCsvHint(url: String): Boolean {
|
||||||
|
return url.contains("FORMAT=csv", ignoreCase = true) ||
|
||||||
|
url.endsWith(".csv", ignoreCase = true) ||
|
||||||
|
url.endsWith(".csv.zip", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun looksLikeCsv(stream: InputStream): Boolean {
|
||||||
|
if (!stream.markSupported()) return false
|
||||||
|
stream.mark(4096)
|
||||||
|
val preview = ByteArray(4096)
|
||||||
|
val length = stream.read(preview)
|
||||||
|
stream.reset()
|
||||||
|
if (length <= 0) return false
|
||||||
|
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
||||||
|
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
||||||
|
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
||||||
|
line.count { it == ',' } >= 4
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
|
|||||||
+45
-5
@@ -36,6 +36,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -64,8 +66,16 @@ class SatelliteRepo(
|
|||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
settingsRepo.selectedIds.collect { selectedIds ->
|
settingsRepo.selectedIds.collect { selectedIds ->
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
calculatePasses(
|
||||||
|
time = System.currentTimeMillis(),
|
||||||
|
hoursAhead = settings.hoursAhead,
|
||||||
|
minElevation = settings.minElevation,
|
||||||
|
aosStartMinute = settings.aosStartMinute,
|
||||||
|
aosEndMinute = settings.aosEndMinute,
|
||||||
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
|
modes = settingsRepo.selectedSatModes.value
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +115,15 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
override suspend fun calculatePasses(
|
||||||
|
time: Long,
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
) {
|
||||||
_isCalculating.value = true
|
_isCalculating.value = true
|
||||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
@@ -130,18 +148,40 @@ class SatelliteRepo(
|
|||||||
val newPasses = ArrayList<OrbitalPass>()
|
val newPasses = ArrayList<OrbitalPass>()
|
||||||
for (list in passLists) {
|
for (list in passLists) {
|
||||||
for (pass in list) {
|
for (pass in list) {
|
||||||
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
|
if (
|
||||||
|
pass.losTime > time
|
||||||
|
&& pass.aosTime < timeFuture
|
||||||
|
&& pass.maxElevation > minElevation
|
||||||
|
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||||
|
) {
|
||||||
newPasses.add(pass)
|
newPasses.add(pass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newPasses.sortBy { it.aosTime }
|
newPasses.sortBy { it.aosTime }
|
||||||
delay(1000) // Simulate loading time for better UX
|
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||||
_passes.update { newPasses }
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isAosInRange(
|
||||||
|
aosTime: Long,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean
|
||||||
|
): Boolean {
|
||||||
|
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||||
|
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||||
|
val aosMinute = (localMillis / 60_000L).toInt()
|
||||||
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
|
} else {
|
||||||
|
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||||
|
}
|
||||||
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
|
}
|
||||||
|
|
||||||
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
||||||
val passes = mutableListOf<OrbitalPass>()
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
val endDate = time + hours * 60L * 60L * 1000L
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
|
|||||||
+16
-13
@@ -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 type IDs once when types change, then filter items reactively.
|
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||||
|
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
val ids = localSource.getIdsWithModes(list)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
itemsWithModes.map { items ->
|
||||||
|
filterByQuery(items, query).sortedWith(
|
||||||
|
compareByDescending<SatItem> { it.isSelected }
|
||||||
|
.thenBy { it.name }
|
||||||
|
.thenBy { it.catnum }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||||
|
|
||||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
override fun getModesList() = Sources.satelliteModes
|
||||||
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()
|
||||||
@@ -73,9 +77,8 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTypes(types: List<String>) {
|
override suspend fun setModes(modes: List<String>) {
|
||||||
currentTypes.value = types
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
settingsRepo.setSelectedTypes(types)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import android.hardware.Sensor
|
|||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.WindowManager
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
|
||||||
|
private const val SMOOTHING_FACTOR = 0.15f
|
||||||
|
private const val SENSOR_RATE_US = 16_000
|
||||||
|
|
||||||
class SensorsRepo(
|
class SensorsRepo(
|
||||||
private val sensorManager: SensorManager,
|
private val sensorManager: SensorManager,
|
||||||
private val sensor: Sensor?,
|
private val displayManager: DisplayManager?
|
||||||
private val windowManager: WindowManager
|
) : ISensorsRepo, SensorEventListener {
|
||||||
) : SensorEventListener, ISensorsRepo {
|
|
||||||
|
|
||||||
private val _orientation = MutableStateFlow(Pair(0f, 0f))
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
|
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
private val rotationMatrix = FloatArray(9)
|
private val rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -45,11 +49,7 @@ class SensorsRepo(
|
|||||||
private var smoothPitch = 0f
|
private var smoothPitch = 0f
|
||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
companion object {
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
private const val SMOOTHING_FACTOR = 0.15f
|
|
||||||
}
|
|
||||||
|
|
||||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -62,7 +62,7 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
hasInitialReading = false
|
||||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
@@ -70,16 +70,11 @@ class SensorsRepo(
|
|||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
if (event.sensor == sensor) updateOrientation(event.values)
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDisplayRotation(): Int {
|
private fun getDisplayRotation(): Int {
|
||||||
return try {
|
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
windowManager.defaultDisplay.rotation
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Surface.ROTATION_0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remapForRotation(rotation: Int) {
|
private fun remapForRotation(rotation: Int) {
|
||||||
@@ -101,30 +96,23 @@ class SensorsRepo(
|
|||||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateOrientation(rotationVector: FloatArray) {
|
private fun handleSensorEvent(event: SensorEvent) {
|
||||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||||
remapForRotation(getDisplayRotation())
|
remapForRotation(getDisplayRotation())
|
||||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
|
||||||
|
|
||||||
if (!hasInitialReading) {
|
if (!hasInitialReading) {
|
||||||
smoothAzimuth = magneticAzimuth
|
smoothAzimuth = azimuth
|
||||||
smoothPitch = pitch
|
smoothPitch = pitch
|
||||||
hasInitialReading = true
|
hasInitialReading = true
|
||||||
} else {
|
} else {
|
||||||
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
|
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||||
smoothPitch = lowPass(smoothPitch, pitch)
|
smoothPitch = lowPass(smoothPitch, pitch)
|
||||||
}
|
}
|
||||||
|
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||||
_orientation.value = Pair(
|
|
||||||
round(smoothAzimuth * 10) / 10,
|
|
||||||
round(smoothPitch * 10) / 10
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard exponential low-pass filter. */
|
|
||||||
private fun lowPass(previous: Float, current: Float): Float {
|
private fun lowPass(previous: Float, current: Float): Float {
|
||||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||||
}
|
}
|
||||||
@@ -135,9 +123,10 @@ class SensorsRepo(
|
|||||||
*/
|
*/
|
||||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||||
var delta = current - previous
|
var delta = current - previous
|
||||||
// Normalise delta into the range (-180, 180]
|
|
||||||
while (delta > 180f) delta -= 360f
|
while (delta > 180f) delta -= 360f
|
||||||
while (delta <= -180f) delta += 360f
|
while (delta <= -180f) delta += 360f
|
||||||
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
|
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||||
}
|
}
|
||||||
+46
-38
@@ -54,6 +54,9 @@ class SettingsRepo(
|
|||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
|
private val keyFilterAosInvert = "filterAosInvert"
|
||||||
private val keyNumberOfRadios = "numberOfRadios"
|
private val keyNumberOfRadios = "numberOfRadios"
|
||||||
private val keyNumberOfSatellites = "numberOfSatellites"
|
private val keyNumberOfSatellites = "numberOfSatellites"
|
||||||
private val keyRotatorAddress = "rotatorAddress"
|
private val keyRotatorAddress = "rotatorAddress"
|
||||||
@@ -65,8 +68,7 @@ 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 keySelectedTypes = "selectedTypes"
|
private val keySelectedSatModes = "selectedSatModes"
|
||||||
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"
|
||||||
@@ -82,6 +84,10 @@ class SettingsRepo(
|
|||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
|
private val keyLowElevation = "lowElevation"
|
||||||
|
private val 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"
|
||||||
@@ -90,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 _typesSelection = MutableStateFlow(getSelectedTypes())
|
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -100,10 +106,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) {
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
val typesString = types.joinToString(separatorComma)
|
val modesString = modes.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||||
_typesSelection.value = types
|
_satelliteModeSelection.value = modes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -112,10 +118,10 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedSatModes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, null)
|
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
if (modesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return modesString.split(separatorComma).sorted()
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -127,7 +133,9 @@ class SettingsRepo(
|
|||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +143,17 @@ class SettingsRepo(
|
|||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
|
return PassesSettings(
|
||||||
|
showDeepSpace,
|
||||||
|
hoursAhead,
|
||||||
|
minElevation,
|
||||||
|
aosStartMinute,
|
||||||
|
aosEndMinute,
|
||||||
|
invertAosTimeWindow
|
||||||
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -216,25 +232,6 @@ 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)
|
||||||
@@ -335,6 +332,10 @@ class SettingsRepo(
|
|||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||||
|
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -349,7 +350,11 @@ class SettingsRepo(
|
|||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
|
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||||
|
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -383,6 +388,7 @@ class SettingsRepo(
|
|||||||
private val keyTxRadioName = "txRadioName"
|
private val keyTxRadioName = "txRadioName"
|
||||||
private val keyRxRadioName = "rxRadioName"
|
private val keyRxRadioName = "rxRadioName"
|
||||||
private val keyRadioBaudRate = "radioBaudRate"
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
private val keyRadioSplitMode = "radioSplitMode"
|
||||||
|
|
||||||
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
@@ -396,18 +402,20 @@ class SettingsRepo(
|
|||||||
putString(keyTxRadioName, settings.txRadioName)
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
putString(keyRxRadioName, settings.rxRadioName)
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
putInt(keyRadioBaudRate, settings.baudRate)
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
putBoolean(keyRadioSplitMode, settings.splitMode)
|
||||||
}
|
}
|
||||||
_radioControlSettings.value = settings
|
_radioControlSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
|
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
|
||||||
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
look4SatDao.deleteRadios()
|
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,14 @@ 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()
|
||||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
response.close()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
// Return the body stream directly as the caller is responsible for closing it
|
||||||
|
// That returns the connection to OkHttp's pool
|
||||||
|
response.body.byteStream().buffered()
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
null
|
||||||
|
|||||||
+20
-19
@@ -1,30 +1,29 @@
|
|||||||
package com.rtbishop.look4sat.core.data
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.test.assertContentEquals
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
class Ft817CatProtocolTest {
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_145500000() {
|
fun encodeFrequencyBcd_145500000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_435100000() {
|
fun encodeFrequencyBcd_435100000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_7074000() {
|
fun encodeFrequencyBcd_7074000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,21 +41,21 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x01.toByte(), cmd[4])
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_usb() {
|
fun buildSetModeCommand_usb() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_fm() {
|
fun buildSetModeCommand_fm() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,19 +66,19 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_67_0() {
|
fun encodeCtcssTone_67_0() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_74_4() {
|
fun encodeCtcssTone_74_4() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_141_3() {
|
fun encodeCtcssTone_141_3() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,13 +86,13 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x0B.toByte(), cmd[4])
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildCtcssModeCommand_enable() {
|
fun buildCtcssModeCommand_enable() {
|
||||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -108,6 +107,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(145500000L, result.first)
|
assertEquals(145500000L, result.first)
|
||||||
assertEquals("USB", result.second)
|
assertEquals("USB", result.second)
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(146000000L, result.first)
|
assertEquals(146000000L, result.first)
|
||||||
assertEquals("FM", result.second)
|
assertEquals("FM", result.second)
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildPttCommands() {
|
fun buildPttCommands() {
|
||||||
val on = Ft817CatProtocol.buildPttOnCommand()
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
val off = Ft817CatProtocol.buildPttOffCommand()
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
assertEquals(0x88.toByte(), off[4])
|
assertEquals(0x88.toByte(), off[4])
|
||||||
@@ -144,6 +145,6 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildReadCommand() {
|
fun buildReadCommand() {
|
||||||
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DatabaseRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val dataParser = DataParser(dispatcher)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||||
|
val uri = "content://look4sat/import/satellites"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[uri] = { validCsvStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateTLEFromFile(uri)
|
||||||
|
|
||||||
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import keeps tle support`() = runTest(dispatcher) {
|
||||||
|
val uri = "content://look4sat/import/legacy"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[uri] = { validTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateTLEFromFile(uri)
|
||||||
|
|
||||||
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom data source imports omm csv from web`() = runTest(dispatcher) {
|
||||||
|
val customCsvUrl = "https://example.com/custom-omm.csv"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[customCsvUrl] = { validCsvStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
useCustomTLE = true,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = customCsvUrl,
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validCsvStream(): InputStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun validTleStream(): InputStream = """
|
||||||
|
ISS (ZARYA)
|
||||||
|
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
|
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource : ILocalSource {
|
||||||
|
val insertedEntries = mutableListOf<OrbitalData>()
|
||||||
|
private val insertedRadios = mutableListOf<SatRadio>()
|
||||||
|
|
||||||
|
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||||
|
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||||
|
insertedEntries += entries
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEntries() {
|
||||||
|
insertedEntries.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
|
insertedRadios += radios
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() {
|
||||||
|
insertedRadios.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
|
|
||||||
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
|
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||||
|
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
|
||||||
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
|
databaseState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
|
dataSourcesSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
return DataSourcesSettings(
|
||||||
|
useCustomTLE = false,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = "",
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SelectionRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(25544, "ISS (ZARYA)", false),
|
||||||
|
SatItem(40967, "TIANGONG", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setModes(listOf("REMOVED_MODE"))
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
||||||
|
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(44444, "Zeta", false),
|
||||||
|
SatItem(25544, "Alpha", false),
|
||||||
|
SatItem(40967, "Beta", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setSelection(listOf(40967), true)
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
||||||
|
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource(
|
||||||
|
private val entries: List<SatItem>
|
||||||
|
) : ILocalSource {
|
||||||
|
override suspend fun getEntriesTotal(): Int = entries.size
|
||||||
|
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = entries
|
||||||
|
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteEntries() = Unit
|
||||||
|
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getRadiosTotal(): Int = 0
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepo(
|
||||||
|
selectedModes: List<String>
|
||||||
|
) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
|
||||||
|
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
|
|
||||||
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||||
|
DataSourcesSettings(false, false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
|
selectedSatModes.value = modes
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
|
databaseState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +27,9 @@ data class PassesSettings(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val hoursAhead: Int,
|
val hoursAhead: Int,
|
||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val selectedModes: List<String>
|
val aosStartMinute: Int = 0,
|
||||||
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
|
val invertAosTimeWindow: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -57,7 +59,11 @@ data class OtherSettings(
|
|||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto"
|
val sstvMode: String = "Auto",
|
||||||
|
val lowElevation: Double = 15.0,
|
||||||
|
val highElevation: Double = 45.0,
|
||||||
|
val radarCompassOffset: Float = 0f,
|
||||||
|
val radarCompassOffsetElev: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
@@ -74,12 +80,20 @@ data class RadioControlSettings(
|
|||||||
val rxRadioAddress: String,
|
val rxRadioAddress: String,
|
||||||
val txRadioName: String,
|
val txRadioName: String,
|
||||||
val rxRadioName: String,
|
val rxRadioName: String,
|
||||||
val baudRate: Int
|
val baudRate: Int,
|
||||||
|
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
||||||
|
val splitMode: Boolean = false
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val SUPPORTED_RADIOS = listOf(
|
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
||||||
"Yaesu FT-817/818",
|
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
||||||
"Yaesu FT-857/897"
|
const val MODEL_ICOM_IC705 = "Icom IC-705"
|
||||||
)
|
|
||||||
|
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
|
||||||
|
|
||||||
|
/** Baud rates available for Yaesu radios. */
|
||||||
|
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
|
||||||
|
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
|
||||||
|
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -18,8 +18,8 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
interface IDatabaseRepo {
|
interface IDatabaseRepo {
|
||||||
suspend fun updateTLEFromFile(uri: String)
|
suspend fun updateTLEFromFile(uri: String): Int
|
||||||
suspend fun updateTransceiversFromFile(uri: String)
|
suspend fun updateTransceiversFromFile(uri: String): Int
|
||||||
suspend fun updateFromRemote()
|
suspend fun updateFromRemote()
|
||||||
suspend fun clearAllData()
|
suspend fun clearAllData()
|
||||||
}
|
}
|
||||||
+1
@@ -39,6 +39,7 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
|
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
+47
@@ -38,4 +38,51 @@ interface IRadioController {
|
|||||||
suspend fun pttOn(): Boolean
|
suspend fun pttOn(): Boolean
|
||||||
|
|
||||||
suspend fun pttOff(): Boolean
|
suspend fun pttOff(): Boolean
|
||||||
|
|
||||||
|
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the band matching [frequencyHz] via the band stacking register.
|
||||||
|
* Must be called before [setFrequency] and [setMode] when first tracking.
|
||||||
|
* Default: no-op (Yaesu radios auto-switch band via frequency).
|
||||||
|
*/
|
||||||
|
suspend fun setBand(frequencyHz: Long): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the active VFO.
|
||||||
|
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
|
||||||
|
*/
|
||||||
|
suspend fun setVfo(vfoA: Boolean): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
|
||||||
|
* Default: not supported.
|
||||||
|
*/
|
||||||
|
suspend fun setSplitMode(enabled: Boolean): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||||
|
* Default: delegates to [setFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||||
|
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
|
||||||
|
* Default: delegates to [setWorkingFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||||
|
* Default: delegates to [readFrequencyAndMode].
|
||||||
|
*/
|
||||||
|
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
* Default: delegates to [readWorkingFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
|
||||||
}
|
}
|
||||||
+9
-1
@@ -44,7 +44,15 @@ interface ISatelliteRepo {
|
|||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
|
|
||||||
/** Recalculate passes with the given filter parameters. */
|
/** Recalculate passes with the given filter parameters. */
|
||||||
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
|
suspend fun calculatePasses(
|
||||||
|
time: Long,
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
/** Get the current position of a single satellite. */
|
/** Get the current position of a single satellite. */
|
||||||
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
||||||
|
|||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentTypes(): List<String>
|
fun getCurrentModes(): List<String>
|
||||||
fun getTypesList(): List<String>
|
fun getModesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||||
suspend fun setTypes(types: List<String>)
|
suspend fun setModes(modes: List<String>)
|
||||||
suspend fun setQuery(query: String)
|
suspend fun 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)
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val orientation: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
+2
-4
@@ -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 selectedTypes: StateFlow<List<String>>
|
val selectedSatModes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedTypes(types: List<String>)
|
fun setSelectedSatModes(modes: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -51,8 +51,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -19,36 +19,25 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
|
||||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
|
||||||
"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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -20,38 +20,94 @@ package com.rtbishop.look4sat.core.domain.sstv
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.PI
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class SstvFrame(
|
class SstvFrame(
|
||||||
|
val scopePixels: IntArray?,
|
||||||
|
val scopeWidth: Int,
|
||||||
|
val scopeHeight: Int,
|
||||||
val imagePixels: IntArray?,
|
val imagePixels: IntArray?,
|
||||||
val imageWidth: Int,
|
val imageWidth: Int,
|
||||||
val imageHeight: Int,
|
val imageHeight: Int,
|
||||||
val modeName: String
|
val modeName: String,
|
||||||
|
val imageComplete: Boolean,
|
||||||
|
val inputRms: Float,
|
||||||
|
val appliedGain: Float,
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
|
||||||
|
* performance on noisy recordings.
|
||||||
|
*/
|
||||||
|
data class SstvQualityMetrics(
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class LineRecoveryStrategy {
|
||||||
|
Look4SatLimited,
|
||||||
|
Robot36Compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
class SstvDiagnosticsHandle internal constructor(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val metrics: StateFlow<SstvQualityMetrics?>
|
||||||
)
|
)
|
||||||
|
|
||||||
class SstvDecoder(
|
class SstvDecoder(
|
||||||
sampleRate: Int = 44100,
|
sampleRate: Int = 44100,
|
||||||
scopeWidth: Int = 320,
|
scopeWidth: Int = 320,
|
||||||
scopeHeight: Int = 256,
|
scopeHeight: Int = 256,
|
||||||
private val channelSelect: Int = 0
|
private val channelSelect: Int = 0,
|
||||||
|
targetRmsLevel: Float = 0.25f,
|
||||||
|
private val includeScopeData: Boolean = false,
|
||||||
|
private val enableRmsNormalization: Boolean = true,
|
||||||
|
preFilterCutoffHz: Double = 500.0,
|
||||||
|
enablePreFilter: Boolean = true,
|
||||||
|
private val enableDiagnosticsHandle: Boolean = false,
|
||||||
|
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
|
||||||
) {
|
) {
|
||||||
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||||
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
||||||
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
|
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
|
||||||
private val _frames = MutableSharedFlow<SstvFrame>(
|
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||||
replay = 1,
|
replay = 1,
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
)
|
)
|
||||||
|
private var lastInputRms = 0f
|
||||||
|
private var lastAppliedGain = 1f
|
||||||
|
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
|
||||||
|
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
|
||||||
|
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
|
||||||
val frames: SharedFlow<SstvFrame> = _frames
|
val frames: SharedFlow<SstvFrame> = _frames
|
||||||
val supportedModes: List<String> = buildList {
|
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||||
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
|
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
|
||||||
|
// weak signals and corrupt the RMS normalization baseline.
|
||||||
|
preFilter?.apply(samples)
|
||||||
|
|
||||||
|
// Optional RMS normalization: bring input to a consistent level so the
|
||||||
|
// FM demodulator operates in a predictable region. However, this amplifies
|
||||||
|
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
|
||||||
|
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
|
||||||
|
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
|
||||||
|
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
|
||||||
|
lastInputRms = gain.inputRms
|
||||||
|
lastAppliedGain = gain.appliedGain
|
||||||
val hasNewLines = decoder.process(samples, channelSelect)
|
val hasNewLines = decoder.process(samples, channelSelect)
|
||||||
if (hasNewLines) emitFrame()
|
if (hasNewLines) emitFrame()
|
||||||
}
|
}
|
||||||
@@ -61,17 +117,134 @@ class SstvDecoder(
|
|||||||
fun clearPixels() {
|
fun clearPixels() {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
imageBuffer.pixels.fill(0)
|
imageBuffer.pixels.fill(0)
|
||||||
|
decoder.resetQuality()
|
||||||
|
if (enableDiagnosticsHandle) _qualityMetrics.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export quality metrics for logging/diagnostics. Useful for profiling decode
|
||||||
|
* performance on noisy recordings. Returns null before first frame is emitted.
|
||||||
|
*/
|
||||||
|
@Suppress("unused")
|
||||||
|
fun getQualityMetrics(): SstvQualityMetrics? {
|
||||||
|
if (enableDiagnosticsHandle) return _qualityMetrics.value
|
||||||
|
val q = decoder.quality()
|
||||||
|
return SstvQualityMetrics(
|
||||||
|
syncHitRate = q.syncHitRate,
|
||||||
|
predictedLineBursts = q.predictedLineBursts,
|
||||||
|
maxPredictedStreak = q.maxPredictedStreak,
|
||||||
|
timingErrorSamples = q.timingErrorSamples
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emitFrame() {
|
private fun emitFrame() {
|
||||||
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
|
|
||||||
val imageWidth = imageBuffer.width
|
val imageWidth = imageBuffer.width
|
||||||
val imageHeight = imageBuffer.height
|
val imageHeight = imageBuffer.height
|
||||||
|
val quality = decoder.quality()
|
||||||
|
if (enableDiagnosticsHandle) {
|
||||||
|
_qualityMetrics.value = SstvQualityMetrics(
|
||||||
|
syncHitRate = quality.syncHitRate,
|
||||||
|
predictedLineBursts = quality.predictedLineBursts,
|
||||||
|
maxPredictedStreak = quality.maxPredictedStreak,
|
||||||
|
timingErrorSamples = quality.timingErrorSamples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
|
||||||
|
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
||||||
|
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
||||||
|
// the entire array and send padding pixels to the observer.
|
||||||
|
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
||||||
val modeName = decoder.currentMode.name
|
val modeName = decoder.currentMode.name
|
||||||
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
|
val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
|
||||||
|
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
|
||||||
|
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
|
||||||
|
_frames.tryEmit(
|
||||||
|
SstvFrame(
|
||||||
|
scopePixels = scopePixels,
|
||||||
|
scopeWidth = scopeWidth,
|
||||||
|
scopeHeight = scopeHeight,
|
||||||
|
imagePixels = imagePixels,
|
||||||
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
|
modeName = modeName,
|
||||||
|
imageComplete = imageComplete,
|
||||||
|
inputRms = lastInputRms,
|
||||||
|
appliedGain = lastAppliedGain,
|
||||||
|
syncHitRate = quality.syncHitRate,
|
||||||
|
predictedLineBursts = quality.predictedLineBursts,
|
||||||
|
maxPredictedStreak = quality.maxPredictedStreak,
|
||||||
|
timingErrorSamples = quality.timingErrorSamples
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||||
|
// FM demodulator well above its noise floor regardless of input gain.
|
||||||
|
// TUNING GUIDE:
|
||||||
|
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
|
||||||
|
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
|
||||||
|
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
|
||||||
|
// Disable RMS normalization entirely if using a professional receiver discriminator output.
|
||||||
|
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
|
||||||
|
|
||||||
|
private class GainInfo(
|
||||||
|
val inputRms: Float,
|
||||||
|
val appliedGain: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
||||||
|
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
||||||
|
private fun normalise(buffer: FloatArray): GainInfo {
|
||||||
|
var sumSq = 0f
|
||||||
|
for (s in buffer) sumSq += s * s
|
||||||
|
val rms = sqrt(sumSq / buffer.size)
|
||||||
|
if (rms > 1e-6f) {
|
||||||
|
val gain = targetRms / rms
|
||||||
|
for (i in buffer.indices) buffer[i] *= gain
|
||||||
|
return GainInfo(rms, gain)
|
||||||
|
}
|
||||||
|
return GainInfo(rms, 1f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
|
||||||
|
* normalization. Improves noise floor estimation and prevents low-freq noise from
|
||||||
|
* corrupting the gain calculation.
|
||||||
|
*
|
||||||
|
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
|
||||||
|
* suitable for real-time preprocessing.
|
||||||
|
*/
|
||||||
|
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
|
||||||
|
private val alpha: Float
|
||||||
|
private var prevInput = 0f
|
||||||
|
private var prevOutput = 0f
|
||||||
|
|
||||||
|
init {
|
||||||
|
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
|
||||||
|
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
|
||||||
|
alpha = omega / (omega + 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(buffer: FloatArray) {
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
val input = buffer[i]
|
||||||
|
prevOutput = alpha * (prevOutput + input - prevInput)
|
||||||
|
buffer[i] = prevOutput
|
||||||
|
prevInput = input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DecoderQuality(
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
||||||
|
|
||||||
internal class SyncPulseDetector(sampleRate: Int) {
|
internal class SyncPulseDetector(sampleRate: Int) {
|
||||||
@@ -136,6 +309,10 @@ internal class SyncPulseDetector(sampleRate: Int) {
|
|||||||
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
|
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
var detected = false
|
var detected = false
|
||||||
val channels = if (channelSelect > 0) 2 else 1
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
|
// NOTE: buffer[i] is overwritten in-place with the FM-demodulated frequency
|
||||||
|
// value for every mono sample (channelSelect == 0). DecoderEngine.process()
|
||||||
|
// reads back these values to populate scanLineBuffer. Callers must not reuse
|
||||||
|
// the buffer after this call.
|
||||||
for (i in 0 until buffer.size / channels) {
|
for (i in 0 until buffer.size / channels) {
|
||||||
when (channelSelect) {
|
when (channelSelect) {
|
||||||
1 -> baseBand.set(buffer[2 * i])
|
1 -> baseBand.set(buffer[2 * i])
|
||||||
@@ -173,7 +350,8 @@ internal class DecoderEngine(
|
|||||||
private val scopeBuffer: PixelBuffer,
|
private val scopeBuffer: PixelBuffer,
|
||||||
private val imageBuffer: PixelBuffer,
|
private val imageBuffer: PixelBuffer,
|
||||||
rawName: String,
|
rawName: String,
|
||||||
sampleRate: Int
|
sampleRate: Int,
|
||||||
|
lineRecoveryStrategy: LineRecoveryStrategy
|
||||||
) {
|
) {
|
||||||
private val pixelBuffer = PixelBuffer(800, 2)
|
private val pixelBuffer = PixelBuffer(800, 2)
|
||||||
private val detector = SyncPulseDetector(sampleRate)
|
private val detector = SyncPulseDetector(sampleRate)
|
||||||
@@ -200,7 +378,6 @@ internal class DecoderEngine(
|
|||||||
private val visBitLen: Int
|
private val visBitLen: Int
|
||||||
private val visLen: Int
|
private val visLen: Int
|
||||||
private val rawMode: SstvMode
|
private val rawMode: SstvMode
|
||||||
private val hfFaxMode: SstvMode
|
|
||||||
private val modes5ms: ArrayList<SstvMode>
|
private val modes5ms: ArrayList<SstvMode>
|
||||||
private val modes9ms: ArrayList<SstvMode>
|
private val modes9ms: ArrayList<SstvMode>
|
||||||
private val modes20ms: ArrayList<SstvMode>
|
private val modes20ms: ArrayList<SstvMode>
|
||||||
@@ -214,9 +391,26 @@ internal class DecoderEngine(
|
|||||||
private var lastSync = 0
|
private var lastSync = 0
|
||||||
private var curLineSamples: Int
|
private var curLineSamples: Int
|
||||||
private var lastOffset = 0f
|
private var lastOffset = 0f
|
||||||
|
private var syncChecks = 0
|
||||||
|
private var syncHits = 0
|
||||||
|
private var predictedLineBursts = 0
|
||||||
|
private var predictedStreak = 0
|
||||||
|
private var maxPredictedStreak = 0
|
||||||
|
private var timingErrorSamples = 0
|
||||||
|
|
||||||
|
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
|
||||||
|
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
|
||||||
|
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
|
||||||
|
LineRecoveryStrategy.Look4SatLimited -> 2
|
||||||
|
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
|
// Pre-allocate for the largest possible mode (PD-290: 800×616 = 492 800 ints)
|
||||||
|
// so that handleHeader/processPulse can reuse the array with a fill(0) instead
|
||||||
|
// of allocating a fresh IntArray on every new image, reducing GC pressure.
|
||||||
|
imageBuffer.pixels = IntArray(800 * 616)
|
||||||
val pfLen = round(0.0025 * sampleRate).toInt() or 1
|
val pfLen = round(0.0025 * sampleRate).toInt() or 1
|
||||||
pulseFilterDelay = (pfLen - 1) / 2
|
pulseFilterDelay = (pfLen - 1) / 2
|
||||||
pulseFilter = MovingAverage(pfLen)
|
pulseFilter = MovingAverage(pfLen)
|
||||||
@@ -231,7 +425,6 @@ internal class DecoderEngine(
|
|||||||
syncTolerance = round(0.03 * sampleRate).toInt()
|
syncTolerance = round(0.03 * sampleRate).toInt()
|
||||||
lineTolerance = round(0.001 * sampleRate).toInt()
|
lineTolerance = round(0.001 * sampleRate).toInt()
|
||||||
rawMode = RawMode(rawName, sampleRate)
|
rawMode = RawMode(rawName, sampleRate)
|
||||||
hfFaxMode = HfFaxMode(sampleRate)
|
|
||||||
val robot36 = Robot36Mode(sampleRate)
|
val robot36 = Robot36Mode(sampleRate)
|
||||||
currentMode = robot36
|
currentMode = robot36
|
||||||
curLineSamples = robot36.scanLineSamples
|
curLineSamples = robot36.scanLineSamples
|
||||||
@@ -260,6 +453,11 @@ internal class DecoderEngine(
|
|||||||
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
var newLines = false
|
var newLines = false
|
||||||
val detected = detector.process(recordBuffer, channelSelect)
|
val detected = detector.process(recordBuffer, channelSelect)
|
||||||
|
syncChecks++
|
||||||
|
if (detected) {
|
||||||
|
syncHits++
|
||||||
|
predictedStreak = 0
|
||||||
|
}
|
||||||
var syncIdx = sample + detector.pulseOffset
|
var syncIdx = sample + detector.pulseOffset
|
||||||
val channels = if (channelSelect > 0) 2 else 1
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
for (j in 0 until recordBuffer.size / channels) {
|
for (j in 0 until recordBuffer.size / channels) {
|
||||||
@@ -282,30 +480,16 @@ internal class DecoderEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (handleHeader()) {
|
} else if (handleHeader()) {
|
||||||
|
predictedStreak = 0
|
||||||
newLines = true
|
newLines = true
|
||||||
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||||
copyLines(
|
newLines = decodePredictedLine()
|
||||||
currentMode.decodeScanLine(
|
|
||||||
pixelBuffer,
|
|
||||||
scratch,
|
|
||||||
scanLineBuffer,
|
|
||||||
scopeBuffer.width,
|
|
||||||
lastSync,
|
|
||||||
curLineSamples,
|
|
||||||
lastOffset
|
|
||||||
)
|
|
||||||
)
|
|
||||||
lastSync += curLineSamples; newLines = true
|
|
||||||
}
|
}
|
||||||
return newLines
|
return newLines
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMode(name: String) {
|
fun setMode(name: String) {
|
||||||
if (rawMode.name == name) {
|
val mode = allModes.firstOrNull { it.name == name }
|
||||||
lockMode = true; imageBuffer.line = -1; currentMode = rawMode; return
|
|
||||||
}
|
|
||||||
var mode = allModes.firstOrNull { it.name == name }
|
|
||||||
if (mode == null && hfFaxMode.name == name) mode = hfFaxMode
|
|
||||||
if (mode == currentMode) {
|
if (mode == currentMode) {
|
||||||
lockMode = true; return
|
lockMode = true; return
|
||||||
}
|
}
|
||||||
@@ -315,6 +499,25 @@ internal class DecoderEngine(
|
|||||||
lockMode = false
|
lockMode = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun quality(): DecoderQuality {
|
||||||
|
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
|
||||||
|
return DecoderQuality(
|
||||||
|
syncHitRate = hitRate,
|
||||||
|
predictedLineBursts = predictedLineBursts,
|
||||||
|
maxPredictedStreak = maxPredictedStreak,
|
||||||
|
timingErrorSamples = timingErrorSamples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetQuality() {
|
||||||
|
syncChecks = 0
|
||||||
|
syncHits = 0
|
||||||
|
predictedLineBursts = 0
|
||||||
|
predictedStreak = 0
|
||||||
|
maxPredictedStreak = 0
|
||||||
|
timingErrorSamples = 0
|
||||||
|
}
|
||||||
|
|
||||||
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
||||||
private fun stdDev(a: IntArray, m: Double): Double {
|
private fun stdDev(a: IntArray, m: Double): Double {
|
||||||
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
||||||
@@ -335,6 +538,10 @@ internal class DecoderEngine(
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scopeBuffer is twice the display height. Each decoded scan line is written
|
||||||
|
// to both the current rolling position (top half, wraps at height/2) and the
|
||||||
|
// same row offset in the bottom half. The UI displays a window that always
|
||||||
|
// spans the half-height boundary, giving a seamless non-wrapping scroll effect.
|
||||||
private fun copyUnscaled() {
|
private fun copyUnscaled() {
|
||||||
val w = minOf(scopeBuffer.width, pixelBuffer.width)
|
val w = minOf(scopeBuffer.width, pixelBuffer.width)
|
||||||
for (row in 0 until pixelBuffer.height) {
|
for (row in 0 until pixelBuffer.height) {
|
||||||
@@ -400,6 +607,29 @@ internal class DecoderEngine(
|
|||||||
if (finish) drawLines(0xff000000.toInt(), 10)
|
if (finish) drawLines(0xff000000.toInt(), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodePredictedLine(): Boolean {
|
||||||
|
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
|
||||||
|
// real sync to reduce visible vertical compression on weak/noisy signals.
|
||||||
|
if (predictedStreak >= maxConsecutivePredictedLines) return false
|
||||||
|
val expectedSync = lastSync + curLineSamples
|
||||||
|
timingErrorSamples = sample - expectedSync
|
||||||
|
val decoded = currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
lastSync,
|
||||||
|
curLineSamples,
|
||||||
|
lastOffset
|
||||||
|
)
|
||||||
|
copyLines(decoded)
|
||||||
|
lastSync = expectedSync
|
||||||
|
predictedLineBursts++
|
||||||
|
predictedStreak++
|
||||||
|
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
private fun drawLines(color: Int, count: Int) {
|
private fun drawLines(color: Int, count: Int) {
|
||||||
repeat(count) {
|
repeat(count) {
|
||||||
scopeBuffer.pixels.fill(
|
scopeBuffer.pixels.fill(
|
||||||
@@ -424,6 +654,9 @@ internal class DecoderEngine(
|
|||||||
if ((amount <= 0) || (amount > sample)) return
|
if ((amount <= 0) || (amount > sample)) return
|
||||||
sample -= amount; leaderBreak -= amount; lastSync -= amount
|
sample -= amount; leaderBreak -= amount; lastSync -= amount
|
||||||
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
|
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
|
||||||
|
// Discard already-decoded samples by sliding the live region back to index 0.
|
||||||
|
// System.arraycopy handles the overlapping regions correctly and is a native
|
||||||
|
// memcpy on JVM, so this is fast despite moving the full remaining window.
|
||||||
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
|
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +727,7 @@ internal class DecoderEngine(
|
|||||||
if (lockMode && mode != currentMode) return false
|
if (lockMode && mode != currentMode) return false
|
||||||
mode.resetState()
|
mode.resetState()
|
||||||
imageBuffer.width = mode.width; imageBuffer.height = mode.height
|
imageBuffer.width = mode.width; imageBuffer.height = mode.height
|
||||||
imageBuffer.pixels = IntArray(mode.width * mode.height); imageBuffer.line = 0
|
imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0
|
||||||
currentMode = mode
|
currentMode = mode
|
||||||
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
|
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
|
||||||
var oldest = lastSync - (pulses.size - 1) * curLineSamples
|
var oldest = lastSync - (pulses.size - 1) * curLineSamples
|
||||||
@@ -513,6 +746,7 @@ internal class DecoderEngine(
|
|||||||
lineLen: IntArray,
|
lineLen: IntArray,
|
||||||
latest: Int
|
latest: Int
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
predictedStreak = 0
|
||||||
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
||||||
syncPulses[syncPulses.size - 1] = latest
|
syncPulses[syncPulses.size - 1] = latest
|
||||||
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
||||||
@@ -527,6 +761,15 @@ internal class DecoderEngine(
|
|||||||
var changed = false
|
var changed = false
|
||||||
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
|
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
|
||||||
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
|
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
|
||||||
|
// Try continuous decoding
|
||||||
|
if (lockMode && imageBuffer.line == -1 && currentMode != rawMode) {
|
||||||
|
currentMode.resetState()
|
||||||
|
imageBuffer.width = currentMode.width
|
||||||
|
imageBuffer.height = currentMode.height
|
||||||
|
imageBuffer.pixels.fill(0, 0, currentMode.width * currentMode.height)
|
||||||
|
imageBuffer.line = 0
|
||||||
|
drawLines(0xff000000.toInt(), 10); drawLines(0xffffff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
|
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
|
||||||
changed =
|
changed =
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.sstv
|
package com.rtbishop.look4sat.core.domain.sstv
|
||||||
|
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.atan2
|
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
@@ -34,7 +33,6 @@ internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
|
|||||||
fun set(real: Float): Complex = set(real, 0f)
|
fun set(real: Float): Complex = set(real, 0f)
|
||||||
|
|
||||||
fun abs(): Float = sqrt(real * real + imag * imag)
|
fun abs(): Float = sqrt(real * real + imag * imag)
|
||||||
fun arg(): Float = atan2(imag, real)
|
|
||||||
|
|
||||||
fun mul(other: Complex): Complex {
|
fun mul(other: Complex): Complex {
|
||||||
val tmp = real * other.real - imag * other.imag
|
val tmp = real * other.real - imag * other.imag
|
||||||
@@ -74,21 +72,21 @@ internal object WindowFunctions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// O(1) ring buffer — simpler and faster than a segment tree for the short window
|
||||||
|
// lengths used here (≤512 samples). Float32 accumulated drift over such windows
|
||||||
|
// is ~6e-5, negligible for audio-frequency processing.
|
||||||
internal open class MovingSum(val length: Int) {
|
internal open class MovingSum(val length: Int) {
|
||||||
private val tree = FloatArray(2 * length)
|
private val buf = FloatArray(length)
|
||||||
private var leaf = length
|
private var pos = 0
|
||||||
|
private var runningSum = 0f
|
||||||
|
|
||||||
fun add(input: Float) {
|
fun add(input: Float) {
|
||||||
tree[leaf] = input
|
runningSum += input - buf[pos]
|
||||||
var child = leaf
|
buf[pos] = input
|
||||||
var parent = leaf / 2
|
if (++pos >= length) pos = 0
|
||||||
while (parent > 0) {
|
|
||||||
tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
|
|
||||||
}
|
|
||||||
if (++leaf >= tree.size) leaf = length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sum(): Float = tree[1]
|
fun sum(): Float = runningSum
|
||||||
fun sum(input: Float): Float {
|
fun sum(input: Float): Float {
|
||||||
add(input); return sum()
|
add(input); return sum()
|
||||||
}
|
}
|
||||||
@@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) {
|
|||||||
val omega = 2 * PI * freq / rate
|
val omega = 2 * PI * freq / rate
|
||||||
Complex(cos(omega).toFloat(), sin(omega).toFloat())
|
Complex(cos(omega).toFloat(), sin(omega).toFloat())
|
||||||
}
|
}
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
fun rotate(): Complex = value.div(value.mul(delta).abs())
|
// Renormalize every 512 rotations to prevent magnitude drift accumulation,
|
||||||
|
// eliminating the per-sample sqrt without sacrificing demodulation accuracy.
|
||||||
|
fun rotate(): Complex {
|
||||||
|
value.mul(delta)
|
||||||
|
if (++count == 512) { value.div(value.abs()); count = 0 }
|
||||||
|
return value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
||||||
@@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
|||||||
private var prev = 0f
|
private var prev = 0f
|
||||||
|
|
||||||
fun demodulate(input: Complex): Float {
|
fun demodulate(input: Complex): Float {
|
||||||
val phase = input.arg()
|
// Use fast polynomial atan2 instead of the exact trigonometric call.
|
||||||
|
// Max error ~0.005 rad translates to <1 Hz frequency error at 44100 Hz,
|
||||||
|
// well within the 50 Hz sync tolerance.
|
||||||
|
val phase = fastAtan2(input.imag, input.real)
|
||||||
var delta = phase - prev; prev = phase
|
var delta = phase - prev; prev = phase
|
||||||
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
|
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
|
||||||
return scale * delta
|
return scale * delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rajan's polynomial approximation of atan2 — avoids a transcendental call
|
||||||
|
// in the per-sample hot path (~44 k calls/s at 44100 Hz sample rate).
|
||||||
|
private fun fastAtan2(y: Float, x: Float): Float {
|
||||||
|
val absY = kotlin.math.abs(y) + 1e-10f
|
||||||
|
val r: Float
|
||||||
|
val angle: Float
|
||||||
|
if (x >= 0f) {
|
||||||
|
r = (x - absY) / (x + absY)
|
||||||
|
angle = 0.1963f * r * r * r - 0.9817f * r + pi / 4f
|
||||||
|
} else {
|
||||||
|
r = (x + absY) / (absY - x)
|
||||||
|
angle = 0.1963f * r * r * r - 0.9817f * r + 3f * pi / 4f
|
||||||
|
}
|
||||||
|
return if (y < 0f) -angle else angle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class ComplexFirFilter(val length: Int) {
|
internal class ComplexFirFilter(val length: Int) {
|
||||||
|
|||||||
@@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode {
|
|||||||
val lPos = lumBegin + (i * lumSamples) / width
|
val lPos = lumBegin + (i * lumSamples) / width
|
||||||
val cPos = chromBegin + (i * chromSamples) / width
|
val cPos = chromBegin + (i * chromSamples) / width
|
||||||
if (even) {
|
if (even) {
|
||||||
|
// Even line: store Y in the red channel slot and Cr in the blue slot,
|
||||||
|
// using ColorConverter.rgb() as a convenient 3×byte packer (not RGB).
|
||||||
|
// The odd line will read these back and combine with its own Cb to
|
||||||
|
// produce the final YUV→RGB conversion for both rows.
|
||||||
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
|
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
|
||||||
} else {
|
} else {
|
||||||
val evenYuv = pixelBuffer.pixels[i]
|
val evenYuv = pixelBuffer.pixels[i]
|
||||||
|
// Even pixel packing: 0xAARRGGBB → Y=RR, Cb=GG(unused), Cr=BB
|
||||||
|
// Odd pixel: Y=lPos, Cb=cPos, Cr=(borrowed from even's BB slot)
|
||||||
|
// Merge: take Y+Cr from even row (bits 0x00ff00ff) and Cb from odd (0x0000ff00).
|
||||||
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
|
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
|
||||||
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
|
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
|
||||||
pixelBuffer.pixels[i + width] =
|
pixelBuffer.pixels[i + width] =
|
||||||
@@ -389,53 +396,6 @@ internal class PdMode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class HfFaxMode(private val sampleRate: Int) : SstvMode {
|
|
||||||
override val name = "HF Fax"
|
|
||||||
override val visCode = -1
|
|
||||||
override val width = 640
|
|
||||||
override val height = 1200
|
|
||||||
override val firstPixelSampleIndex = 0
|
|
||||||
override val firstSyncPulseIndex = -1
|
|
||||||
override val scanLineSamples get() = sampleRate / 2
|
|
||||||
|
|
||||||
private val ema = Ema()
|
|
||||||
private val cumulated = FloatArray(width)
|
|
||||||
|
|
||||||
var horizontalShift = 0; private set
|
|
||||||
|
|
||||||
override fun decodeScanLine(
|
|
||||||
pixelBuffer: PixelBuffer,
|
|
||||||
scratch: FloatArray,
|
|
||||||
scanLine: FloatArray,
|
|
||||||
scopeWidth: Int,
|
|
||||||
syncPulseIndex: Int,
|
|
||||||
lineSamples: Int,
|
|
||||||
freqOffset: Float
|
|
||||||
): Boolean {
|
|
||||||
if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
|
|
||||||
ema.setCutoff(width.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
|
|
||||||
for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
|
||||||
ema.reset()
|
|
||||||
for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
|
||||||
var bestIdx = 0
|
|
||||||
var bestVal = 0f
|
|
||||||
for (i in 0 until width) {
|
|
||||||
val pos = (i * lineSamples) / width
|
|
||||||
val color = ColorConverter.gray(scratch[pos])
|
|
||||||
pixelBuffer.pixels[i] = color
|
|
||||||
// Grayscale: R==G==B, so luminance is simply the channel value normalized
|
|
||||||
val gray = (color and 0xFF) / 255f
|
|
||||||
cumulated[i] = cumulated[i] * 0.99f + gray * 0.01f
|
|
||||||
if (cumulated[i] > bestVal) {
|
|
||||||
bestIdx = i; bestVal = cumulated[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
horizontalShift = bestIdx
|
|
||||||
pixelBuffer.width = width; pixelBuffer.height = 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
|
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
|
||||||
override val visCode = -1
|
override val visCode = -1
|
||||||
override val width = -1
|
override val width = -1
|
||||||
|
|||||||
@@ -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(),
|
name = tle[0].trim().removePrefix("0 "),
|
||||||
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,
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
|
*
|
||||||
|
* For a linear (passband) transponder, uplink and downlink frequencies are
|
||||||
|
* related by a fixed passband offset. When the satellite moves, both are
|
||||||
|
* Doppler-shifted. Given one, this computes the other:
|
||||||
|
*
|
||||||
|
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||||
|
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||||
|
*/
|
||||||
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlink(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
|
* with an offset applied to the downlink (in Hz).
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*
|
||||||
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
|
* it before mapping the downlink passband position back to the uplink.
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplink(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getDownlinkFreq(baseDownlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
||||||
|
* with an offset applied to the downlink (in Hz).
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this transponder supports linear passband mapping. */
|
||||||
|
fun isLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
val upLow = transponder.uplinkLow
|
||||||
|
val upHigh = transponder.uplinkHigh
|
||||||
|
val downLow = transponder.downlinkLow
|
||||||
|
val downHigh = transponder.downlinkHigh
|
||||||
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
|
&& upLow != upHigh && downLow != downHigh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the radio entry that should drive the standalone Calculator page.
|
||||||
|
*
|
||||||
|
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||||
|
* can also have low/high frequencies. The calculator is meant for named linear
|
||||||
|
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
|
*/
|
||||||
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|
||||||
|
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||||
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
.lowercase(Locale.ENGLISH)
|
||||||
|
val hasLinearName = info.contains("linear")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.utility
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import kotlin.math.acos
|
import kotlin.math.acos
|
||||||
import kotlin.math.atan2
|
import kotlin.math.atan2
|
||||||
@@ -85,6 +87,13 @@ fun clipLon(longitude: Double): Double {
|
|||||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun OrbitalPos.toMapGeoPos(): GeoPos {
|
||||||
|
return GeoPos(
|
||||||
|
latitude = clipLat(latitude.toDegrees()),
|
||||||
|
longitude = clipLon(longitude.toDegrees())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
||||||
return min(max(currentValue, minValue), maxValue)
|
return min(max(currentValue, minValue), maxValue)
|
||||||
}
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DopplerFrequencyCalculatorTest {
|
||||||
|
|
||||||
|
private fun linearTransponder(
|
||||||
|
upLow: Long = 145_000_000L,
|
||||||
|
upHigh: Long = 145_500_000L,
|
||||||
|
downLow: Long = 435_000_000L,
|
||||||
|
downHigh: Long? = 435_500_000L,
|
||||||
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
|
) = SatRadio(
|
||||||
|
uuid = "linear", info = info, isAlive = true,
|
||||||
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fmTransponder() = SatRadio(
|
||||||
|
uuid = "fm", info = "FM Repeater", isAlive = true,
|
||||||
|
downlinkLow = 435_600_000L, downlinkHigh = null,
|
||||||
|
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
|
||||||
|
uplinkMode = "FM", isInverted = false, catnum = 99999
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
|
||||||
|
this.distanceRate = distanceRateKmS
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsTrueForLinear() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForFM() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
|
||||||
|
val xpdr = linearTransponder(downHigh = null)
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||||
|
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||||
|
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(downlink)
|
||||||
|
assertEquals(435_200_000L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||||
|
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(7.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertTrue(uplink!! > 145_200_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_202_500L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = 435_202_500L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_197_500L, downlink)
|
||||||
|
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = downlink!!,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertEquals(145_300_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_roundTrip() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(3.5)
|
||||||
|
val originalDownlink = 435_250_000L
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||||
|
assertNotNull(roundTripDownlink)
|
||||||
|
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||||
|
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
+424
@@ -0,0 +1,424 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.LinearGradient
|
||||||
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.core.graphics.withRotation
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class BubblesStyle(
|
||||||
|
val bgColor: Color = Color.Black,
|
||||||
|
val bubbleCount: Int = 16,
|
||||||
|
val minBubbleCount: Int = 8,
|
||||||
|
val maxBubbleCount: Int = 16,
|
||||||
|
val adaptiveBubbleCount: Boolean = true,
|
||||||
|
val spawnIntervalMs: Long = 800L,
|
||||||
|
val bubbleRadiusFraction: Float = 0.24f,
|
||||||
|
val adaptiveSizing: Boolean = true,
|
||||||
|
val referenceMinSizePx: Float = 360f,
|
||||||
|
val referenceAreaPx: Float = 360f * 800f,
|
||||||
|
val minBubbleRadiusPx: Float = 16f,
|
||||||
|
val maxBubbleRadiusPx: Float = 256f,
|
||||||
|
val speedScale: Float = 0.99f,
|
||||||
|
val minVelocity: Float = 0.8f,
|
||||||
|
val maxVelocity: Float = 3.2f,
|
||||||
|
val hueRotationSpeedDps: Float = 60f, // degrees per second
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BubblesEffect(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isRunning: Boolean = true,
|
||||||
|
style: BubblesStyle = BubblesStyle(),
|
||||||
|
) {
|
||||||
|
val renderer = remember { BubblesRenderer() }
|
||||||
|
var frameSignal by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
LaunchedEffect(isRunning, style) {
|
||||||
|
if (!isRunning) return@LaunchedEffect
|
||||||
|
var previousNanos = 0L
|
||||||
|
while (isActive) {
|
||||||
|
withFrameNanos { now ->
|
||||||
|
if (previousNanos == 0L) previousNanos = now
|
||||||
|
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||||
|
previousNanos = now
|
||||||
|
renderer.update(deltaSec, style)
|
||||||
|
frameSignal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
frameSignal
|
||||||
|
renderer.ensureLayout(size)
|
||||||
|
renderer.draw(this, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS
|
||||||
|
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||||
|
private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f)
|
||||||
|
private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f)
|
||||||
|
|
||||||
|
private data class Velocity(var x: Float, var y: Float)
|
||||||
|
|
||||||
|
private data class Bubble(
|
||||||
|
var x: Float,
|
||||||
|
var y: Float,
|
||||||
|
val radius: Float,
|
||||||
|
val velocity: Velocity,
|
||||||
|
val baseHue: Float, // 0-360, unique for each bubble
|
||||||
|
)
|
||||||
|
|
||||||
|
private class BubblesRenderer {
|
||||||
|
private var width = 0f
|
||||||
|
private var height = 0f
|
||||||
|
private val bubbles = mutableListOf<Bubble>()
|
||||||
|
private val random = Random(System.currentTimeMillis())
|
||||||
|
private var globalHueRotation = 0f // degrees, rotates all bubbles hues
|
||||||
|
private var spawnAccumulatorSec = 0f
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val shellColors = IntArray(8)
|
||||||
|
private val innerColors = IntArray(4)
|
||||||
|
|
||||||
|
fun ensureLayout(size: Size) {
|
||||||
|
val targetWidth = size.width
|
||||||
|
val targetHeight = size.height
|
||||||
|
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||||
|
val shouldRebuild = width != targetWidth || height != targetHeight
|
||||||
|
if (shouldRebuild) {
|
||||||
|
width = targetWidth
|
||||||
|
height = targetHeight
|
||||||
|
bubbles.clear()
|
||||||
|
spawnAccumulatorSec = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun spawnBubble(style: BubblesStyle): Boolean {
|
||||||
|
if (width <= 0f || height <= 0f) return false
|
||||||
|
val radius = resolveBubbleRadius(style)
|
||||||
|
val (velocityMin, velocityMax) = resolveVelocityRange(style)
|
||||||
|
val bubble = Bubble(
|
||||||
|
x = radius,
|
||||||
|
y = height - radius,
|
||||||
|
radius = radius,
|
||||||
|
velocity = Velocity(
|
||||||
|
x = randomInRange(velocityMin, velocityMax),
|
||||||
|
y = -randomInRange(velocityMin, velocityMax),
|
||||||
|
),
|
||||||
|
baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble
|
||||||
|
)
|
||||||
|
bubbles.add(bubble)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(deltaSeconds: Float, style: BubblesStyle) {
|
||||||
|
if (width <= 0f || height <= 0f) return
|
||||||
|
|
||||||
|
spawnMissingBubbles(deltaSeconds, style)
|
||||||
|
|
||||||
|
// Rotate hue for all bubbles
|
||||||
|
globalHueRotation += style.hueRotationSpeedDps * deltaSeconds
|
||||||
|
if (globalHueRotation >= 360f) globalHueRotation -= 360f
|
||||||
|
val frameScale = deltaSeconds * 60f
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
val bubble = bubbles[i]
|
||||||
|
// Update position
|
||||||
|
bubble.x += bubble.velocity.x * frameScale
|
||||||
|
bubble.y += bubble.velocity.y * frameScale
|
||||||
|
// Bounce off walls
|
||||||
|
if (bubble.x > width - bubble.radius) {
|
||||||
|
bubble.x = width - bubble.radius
|
||||||
|
bubble.velocity.x *= -1
|
||||||
|
}
|
||||||
|
if (bubble.x < bubble.radius) {
|
||||||
|
bubble.x = bubble.radius
|
||||||
|
bubble.velocity.x *= -1
|
||||||
|
}
|
||||||
|
if (bubble.y > height - bubble.radius) {
|
||||||
|
bubble.y = height - bubble.radius
|
||||||
|
bubble.velocity.y *= -1
|
||||||
|
}
|
||||||
|
if (bubble.y < bubble.radius) {
|
||||||
|
bubble.y = bubble.radius
|
||||||
|
bubble.velocity.y *= -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collision detection and resolution
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
for (j in (i + 1) until bubbles.size) {
|
||||||
|
val b1 = bubbles[i]
|
||||||
|
val b2 = bubbles[j]
|
||||||
|
if (isCollided(b1, b2)) resolveCollision(b1, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) {
|
||||||
|
val targetCount = resolveTargetBubbleCount(style)
|
||||||
|
if (bubbles.size >= targetCount) return
|
||||||
|
|
||||||
|
val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f
|
||||||
|
spawnAccumulatorSec += deltaSeconds
|
||||||
|
|
||||||
|
while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) {
|
||||||
|
if (!spawnBubble(style)) break
|
||||||
|
spawnAccumulatorSec -= spawnIntervalSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(scope: DrawScope, style: BubblesStyle) {
|
||||||
|
if (width <= 0f || height <= 0f) return
|
||||||
|
scope.drawRect(style.bgColor)
|
||||||
|
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
drawBubbleWithGradient(canvas, bubbles[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) {
|
||||||
|
val hue = (bubble.baseHue + globalHueRotation) % 360f
|
||||||
|
val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f)
|
||||||
|
val rgb = hueToRgb(hue, lit)
|
||||||
|
val shellColor = mixWithWhite(rgb, 0.04f)
|
||||||
|
val innerColor = mixWithWhite(rgb, 0.10f)
|
||||||
|
val shimmerColor = mixWithWhite(rgb, 0.35f)
|
||||||
|
// Outer shell
|
||||||
|
val shellGradient = RadialGradient(
|
||||||
|
bubble.x, bubble.y, bubble.radius,
|
||||||
|
buildShellColors(shellColor),
|
||||||
|
SHELL_GRADIENT_STOPS,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.shader = shellGradient
|
||||||
|
canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint)
|
||||||
|
// Subtle thin rim
|
||||||
|
paint.shader = null
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f)
|
||||||
|
paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f)
|
||||||
|
canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint)
|
||||||
|
// Top internal bubble
|
||||||
|
val innerTop = bubble.y - bubble.radius * 0.96f
|
||||||
|
val innerBottom = bubble.y + bubble.radius * 0.48f
|
||||||
|
val innerLeft = bubble.x - bubble.radius * 0.84f
|
||||||
|
val innerRight = bubble.x + bubble.radius * 0.84f
|
||||||
|
val innerGradient = LinearGradient(
|
||||||
|
bubble.x,
|
||||||
|
innerTop,
|
||||||
|
bubble.x,
|
||||||
|
innerBottom,
|
||||||
|
buildInnerColors(innerColor),
|
||||||
|
INNER_GRADIENT_STOPS,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.shader = innerGradient
|
||||||
|
canvas.drawOval(
|
||||||
|
innerLeft,
|
||||||
|
innerTop,
|
||||||
|
innerRight,
|
||||||
|
innerBottom,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
// Top-left shimmer
|
||||||
|
paint.shader = null
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.color = colorWithAlpha(shimmerColor, 0.95f)
|
||||||
|
val shimmerCx = bubble.x - bubble.radius * 0.40f
|
||||||
|
val shimmerCy = bubble.y - bubble.radius * 0.72f
|
||||||
|
val shimmerHalfWidth = bubble.radius * 0.06f
|
||||||
|
val shimmerHalfHeight = bubble.radius * 0.21f
|
||||||
|
canvas.withRotation(60f, shimmerCx, shimmerCy) {
|
||||||
|
drawOval(
|
||||||
|
shimmerCx - shimmerHalfWidth,
|
||||||
|
shimmerCy - shimmerHalfHeight,
|
||||||
|
shimmerCx + shimmerHalfWidth,
|
||||||
|
shimmerCy + shimmerHalfHeight,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean {
|
||||||
|
val dx = bubble1.x - bubble2.x
|
||||||
|
val dy = bubble1.y - bubble2.y
|
||||||
|
val radius = (bubble1.radius + bubble2.radius) * 0.9f
|
||||||
|
return dx * dx + dy * dy < radius * radius
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCollision(particle: Bubble, otherParticle: Bubble) {
|
||||||
|
val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x
|
||||||
|
val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y
|
||||||
|
val xDist = otherParticle.x - particle.x
|
||||||
|
val yDist = otherParticle.y - particle.y
|
||||||
|
// Prevent accidental overlap
|
||||||
|
if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {
|
||||||
|
val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x)
|
||||||
|
val m1 = 1f
|
||||||
|
val m2 = 1f
|
||||||
|
val u1 = rotate(particle.velocity, angle)
|
||||||
|
val u2 = rotate(otherParticle.velocity, angle)
|
||||||
|
val v1 = Velocity(
|
||||||
|
x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2),
|
||||||
|
y = u1.y,
|
||||||
|
)
|
||||||
|
val v2 = Velocity(
|
||||||
|
x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2),
|
||||||
|
y = u2.y,
|
||||||
|
)
|
||||||
|
val vFinal1 = rotate(v1, -angle)
|
||||||
|
val vFinal2 = rotate(v2, -angle)
|
||||||
|
particle.velocity.x = vFinal1.x
|
||||||
|
particle.velocity.y = vFinal1.y
|
||||||
|
otherParticle.velocity.x = vFinal2.x
|
||||||
|
otherParticle.velocity.y = vFinal2.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotate(velocity: Velocity, angle: Float): Velocity {
|
||||||
|
return Velocity(
|
||||||
|
x = velocity.x * cos(angle) - velocity.y * sin(angle),
|
||||||
|
y = velocity.x * sin(angle) + velocity.y * cos(angle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildShellColors(shellColor: Int): IntArray {
|
||||||
|
shellColors[0] = colorWithAlpha(shellColor, 0f)
|
||||||
|
shellColors[1] = colorWithAlpha(shellColor, 0f)
|
||||||
|
shellColors[2] = colorWithAlpha(shellColor, 0.04f)
|
||||||
|
shellColors[3] = colorWithAlpha(shellColor, 0.12f)
|
||||||
|
shellColors[4] = colorWithAlpha(shellColor, 0.28f)
|
||||||
|
shellColors[5] = colorWithAlpha(shellColor, 0.44f)
|
||||||
|
shellColors[6] = colorWithAlpha(shellColor, 0.58f)
|
||||||
|
shellColors[7] = colorWithAlpha(shellColor, 0.64f)
|
||||||
|
return shellColors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildInnerColors(innerColor: Int): IntArray {
|
||||||
|
innerColors[0] = colorWithAlpha(innerColor, 0.48f)
|
||||||
|
innerColors[1] = colorWithAlpha(innerColor, 0.36f)
|
||||||
|
innerColors[2] = colorWithAlpha(innerColor, 0.08f)
|
||||||
|
innerColors[3] = colorWithAlpha(innerColor, 0f)
|
||||||
|
return innerColors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBubbleRadius(style: BubblesStyle): Float {
|
||||||
|
val minDimension = min(width, height)
|
||||||
|
val baseRadius = minDimension * style.bubbleRadiusFraction
|
||||||
|
val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f)
|
||||||
|
val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx)
|
||||||
|
if (!style.adaptiveSizing || minDimension <= 0f) {
|
||||||
|
return baseRadius.coerceIn(minRadius, maxRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
val reference = style.referenceMinSizePx.coerceAtLeast(1f)
|
||||||
|
val dampening = sqrt((reference / minDimension).coerceAtMost(1f))
|
||||||
|
return (baseRadius * dampening).coerceIn(minRadius, maxRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTargetBubbleCount(style: BubblesStyle): Int {
|
||||||
|
val baseCount = style.bubbleCount.coerceAtLeast(1)
|
||||||
|
if (!style.adaptiveBubbleCount) return baseCount
|
||||||
|
|
||||||
|
val area = width * height
|
||||||
|
val referenceArea = style.referenceAreaPx.coerceAtLeast(1f)
|
||||||
|
val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f))
|
||||||
|
val scaledCount = (baseCount * areaScale).toInt()
|
||||||
|
val minCount = style.minBubbleCount.coerceAtLeast(1)
|
||||||
|
val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1))
|
||||||
|
return scaledCount.coerceIn(minCount, maxCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveVelocityRange(style: BubblesStyle): Pair<Float, Float> {
|
||||||
|
val referenceMaxVelocity = maxOf(height / 300f, 1f)
|
||||||
|
val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f)
|
||||||
|
val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale)
|
||||||
|
return velocityMin to velocityMax
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomInRange(min: Float, max: Float): Float {
|
||||||
|
return random.nextFloat() * (max - min) + min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hueToRgb(h: Float, l: Float): Int {
|
||||||
|
val hNorm = h / 360f
|
||||||
|
val lNorm = l / 100f
|
||||||
|
val sNorm = 1f
|
||||||
|
val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm
|
||||||
|
val hp = hNorm * 6f
|
||||||
|
val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f))
|
||||||
|
val m = lNorm - c / 2f
|
||||||
|
val (r, g, b) = when {
|
||||||
|
hp < 1f -> Triple(c, x, 0f)
|
||||||
|
hp < 2f -> Triple(x, c, 0f)
|
||||||
|
hp < 3f -> Triple(0f, c, x)
|
||||||
|
hp < 4f -> Triple(0f, x, c)
|
||||||
|
hp < 5f -> Triple(x, 0f, c)
|
||||||
|
else -> Triple(c, 0f, x)
|
||||||
|
}
|
||||||
|
val r8 = ((r + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
val g8 = ((g + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
val b8 = ((b + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorWithAlpha(color: Int, alpha: Float): Int {
|
||||||
|
val a = (alpha * 255).toInt().coerceIn(0, 255)
|
||||||
|
val r = (color shr 16) and 0xFF
|
||||||
|
val g = (color shr 8) and 0xFF
|
||||||
|
val b = color and 0xFF
|
||||||
|
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mixWithWhite(color: Int, amount: Float): Int {
|
||||||
|
val t = amount.coerceIn(0f, 1f)
|
||||||
|
val r = (color shr 16) and 0xFF
|
||||||
|
val g = (color shr 8) and 0xFF
|
||||||
|
val b = color and 0xFF
|
||||||
|
val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255)
|
||||||
|
val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255)
|
||||||
|
val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB
|
||||||
|
}
|
||||||
+188
-39
@@ -34,30 +34,51 @@ 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
|
||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
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.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
@@ -124,7 +145,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", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -193,6 +214,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -268,55 +294,92 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
fun SharedDialog(
|
fun SharedDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
|
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||||
content()
|
content()
|
||||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
|
||||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
fun SharedDialog(
|
||||||
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
|
title: String,
|
||||||
Text(
|
onDismissRequest: () -> Unit,
|
||||||
text = text,
|
onCancel: (() -> Unit)? = null,
|
||||||
fontSize = 16.sp,
|
onAccept: (() -> Unit)? = null,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
titleFontSize: Int = 16,
|
||||||
modifier = Modifier.padding(horizontal = it)
|
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||||
)
|
content: @Composable (padding: Dp) -> Unit
|
||||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
val dismissible = onCancel != null
|
||||||
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
|
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = padding, top = padding, end = padding)
|
||||||
|
) {
|
||||||
|
if (onCancel != null) {
|
||||||
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = titleFontSize.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = titleTextAlign,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||||
|
)
|
||||||
|
if (onAccept != null) {
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DialogShell(
|
private fun DialogShell(
|
||||||
title: String,
|
|
||||||
titleFontSize: Int,
|
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
|
dismissible: Boolean = true,
|
||||||
|
content: @Composable (padding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
Dialog(onDismissRequest = onDismissRequest) {
|
val sheetState = rememberModalBottomSheetState(
|
||||||
ElevatedCard {
|
skipPartiallyExpanded = true,
|
||||||
Column(
|
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
)
|
||||||
verticalArrangement = Arrangement.spacedBy(padding)
|
val stopSheetFling = remember {
|
||||||
) {
|
object : NestedScrollConnection {
|
||||||
Text(
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
text = title,
|
return available
|
||||||
fontSize = titleFontSize.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
|
||||||
)
|
|
||||||
content(padding)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset = Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = if (dismissible) onDismissRequest else { {} },
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(padding),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.nestedScroll(stopSheetFling)
|
||||||
|
) {
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,9 +444,95 @@ fun TopBar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun elevationColor(elevation: Double): Color {
|
fun elevationColor(elevation: Double): Color {
|
||||||
|
val thresholds = LocalElevationThresholds.current
|
||||||
return when {
|
return when {
|
||||||
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
|
elevation < thresholds.low -> ElevationLowColor // soft red for low elevation
|
||||||
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
else -> Color(0xFF66BB6A) // soft green for high elevation
|
else -> ElevationHighColor // soft green for high elevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-configurable elevation highlight thresholds (in degrees). */
|
||||||
|
data class ElevationThresholds(val low: Double = 15.0, val high: Double = 45.0)
|
||||||
|
|
||||||
|
/** Provided at the app root from settings; defaults keep the original 15°/45° behavior. */
|
||||||
|
val LocalElevationThresholds = compositionLocalOf { ElevationThresholds() }
|
||||||
|
|
||||||
|
/** Soft red used for elevations below the low threshold. */
|
||||||
|
val ElevationLowColor = Color(0xFFEF5350)
|
||||||
|
|
||||||
|
/** Soft green used for elevations above the high threshold. */
|
||||||
|
val ElevationHighColor = Color(0xFF66BB6A)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OutlinedText(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fillColor: Color = Color.Unspecified,
|
||||||
|
outlineColor: Color,
|
||||||
|
fontSize: TextUnit = TextUnit.Unspecified,
|
||||||
|
fontStyle: FontStyle? = null,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
fontFamily: FontFamily? = null,
|
||||||
|
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
textAlign: TextAlign? = null,
|
||||||
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
|
softWrap: Boolean = true,
|
||||||
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
|
minLines: Int = 1,
|
||||||
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
|
style: TextStyle = LocalTextStyle.current,
|
||||||
|
outlineDrawStyle: Stroke = Stroke(width = 8f),
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||||
|
color = outlineColor,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = null,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
|
maxLines = maxLines,
|
||||||
|
minLines = minLines,
|
||||||
|
onTextLayout = onTextLayout,
|
||||||
|
style = style.copy(shadow = null, drawStyle = outlineDrawStyle),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = fillColor,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
|
maxLines = maxLines,
|
||||||
|
minLines = minLines,
|
||||||
|
onTextLayout = onTextLayout,
|
||||||
|
style = style,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats a frequency in Hz as "MMM.KKK.HHH" (e.g. 145.825.000) or "---"
|
||||||
|
fun formatFrequency(frequencyHz: Long): String {
|
||||||
|
if (frequencyHz <= 0) return "---"
|
||||||
|
val mhz = frequencyHz / 1_000_000
|
||||||
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
|
val hz = frequencyHz % 1_000
|
||||||
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
|
}
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MatrixStyle(
|
||||||
|
val bgColor: Color = Color.Black,
|
||||||
|
val bodyColor: Color = Color(0xFF29C94A),
|
||||||
|
val headColor: Color = Color(0xFFC2FFC6),
|
||||||
|
val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f,
|
||||||
|
val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp
|
||||||
|
val minStreamLength: Int = 8, // 6 - 8
|
||||||
|
val maxStreamLength: Int = 28, // 20 - 28
|
||||||
|
val minSpeedRps: Float = 10f, // 10f - 15f
|
||||||
|
val maxSpeedRps: Float = 38f, // 38f - 45f
|
||||||
|
val resetPauseSec: ClosedFloatingPointRange<Float> = 0.1f..1.0f,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MatrixEffect(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isRunning: Boolean = true,
|
||||||
|
style: MatrixStyle = MatrixStyle(),
|
||||||
|
symbols: String = DEFAULT_SYMBOLS,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val renderer = remember { MatrixRenderer() }
|
||||||
|
var frameSignal by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(isRunning, style, symbols) {
|
||||||
|
if (!isRunning) return@LaunchedEffect
|
||||||
|
|
||||||
|
var previousNanos = 0L
|
||||||
|
while (isActive) {
|
||||||
|
withFrameNanos { now ->
|
||||||
|
if (previousNanos == 0L) previousNanos = now
|
||||||
|
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||||
|
previousNanos = now
|
||||||
|
renderer.update(deltaSec, style)
|
||||||
|
frameSignal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
frameSignal
|
||||||
|
renderer.ensureLayout(size, density.density, style, symbols)
|
||||||
|
renderer.draw(this, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_STEP_NANOS = 33_333_333L
|
||||||
|
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||||
|
private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()"
|
||||||
|
|
||||||
|
private fun Color.toArgb(): Int {
|
||||||
|
val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MatrixRenderer {
|
||||||
|
private var columns = 0
|
||||||
|
private var rows = 0
|
||||||
|
private var width = 0f
|
||||||
|
private var height = 0f
|
||||||
|
private var fontSizePx = 0f
|
||||||
|
private var charWidth = 0f
|
||||||
|
private var charHeight = 0f
|
||||||
|
private var baselineOffset = 0f
|
||||||
|
private var symbolSet = ""
|
||||||
|
private var symbols = charArrayOf()
|
||||||
|
|
||||||
|
private var glyphs = charArrayOf()
|
||||||
|
private var streams = emptyArray<StreamState>()
|
||||||
|
|
||||||
|
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||||
|
private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||||
|
private val charBuffer = CharArray(1)
|
||||||
|
private val random = Random(System.currentTimeMillis())
|
||||||
|
|
||||||
|
fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) {
|
||||||
|
val targetFontPx = style.fontSize.value * density
|
||||||
|
val targetWidth = size.width
|
||||||
|
val targetHeight = size.height
|
||||||
|
val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS }
|
||||||
|
|
||||||
|
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||||
|
|
||||||
|
val shouldRebuild = width != targetWidth ||
|
||||||
|
height != targetHeight ||
|
||||||
|
fontSizePx != targetFontPx ||
|
||||||
|
symbolSet != targetSymbols
|
||||||
|
|
||||||
|
if (!shouldRebuild) return
|
||||||
|
|
||||||
|
width = targetWidth
|
||||||
|
height = targetHeight
|
||||||
|
fontSizePx = targetFontPx
|
||||||
|
symbolSet = targetSymbols
|
||||||
|
this.symbols = targetSymbols.toCharArray()
|
||||||
|
|
||||||
|
bodyPaint.textSize = targetFontPx
|
||||||
|
headPaint.textSize = targetFontPx
|
||||||
|
charWidth = max(bodyPaint.measureText("W"), 1f)
|
||||||
|
val metrics = bodyPaint.fontMetrics
|
||||||
|
charHeight = max(metrics.descent - metrics.ascent, 1f)
|
||||||
|
baselineOffset = -metrics.ascent
|
||||||
|
|
||||||
|
columns = max((width / charWidth).toInt(), 1)
|
||||||
|
rows = max((height / charHeight).toInt() + 2, 1)
|
||||||
|
|
||||||
|
glyphs = CharArray(columns * rows) { randomGlyph() }
|
||||||
|
streams = Array(columns) { StreamState.random(rows, style, random) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(deltaSeconds: Float, style: MatrixStyle) {
|
||||||
|
if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return
|
||||||
|
|
||||||
|
for (column in streams.indices) {
|
||||||
|
val stream = streams[column]
|
||||||
|
stream.pauseSec -= deltaSeconds
|
||||||
|
if (stream.pauseSec > 0f) continue
|
||||||
|
|
||||||
|
val previousHead = floor(stream.headRow).toInt()
|
||||||
|
stream.headRow += stream.speedRps * deltaSeconds
|
||||||
|
val newHead = floor(stream.headRow).toInt()
|
||||||
|
|
||||||
|
if (newHead > previousHead) {
|
||||||
|
for (row in (previousHead + 1)..newHead) {
|
||||||
|
if (row in 0 until rows) {
|
||||||
|
glyphs[row * columns + column] = randomGlyph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHead - stream.length > rows) {
|
||||||
|
stream.reset(rows, style, random)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) {
|
||||||
|
if (columns == 0 || rows == 0) return
|
||||||
|
|
||||||
|
scope.drawRect(style.bgColor)
|
||||||
|
|
||||||
|
bodyPaint.color = style.bodyColor.toArgb()
|
||||||
|
headPaint.color = style.headColor.toArgb()
|
||||||
|
val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||||
|
for (column in streams.indices) {
|
||||||
|
val stream = streams[column]
|
||||||
|
if (stream.pauseSec > 0f) continue
|
||||||
|
|
||||||
|
val head = floor(stream.headRow).toInt()
|
||||||
|
val startRow = max(0, head - stream.length + 1)
|
||||||
|
val endRow = min(rows - 1, head)
|
||||||
|
if (startRow > endRow) continue
|
||||||
|
|
||||||
|
for (row in endRow downTo startRow) {
|
||||||
|
val tailIndex = head - row
|
||||||
|
|
||||||
|
val paint = if (tailIndex == 0) headPaint else bodyPaint
|
||||||
|
if (tailIndex != 0) {
|
||||||
|
val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f)
|
||||||
|
val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized
|
||||||
|
paint.alpha = (alpha * 255).toInt()
|
||||||
|
} else {
|
||||||
|
paint.alpha = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
val glyph = glyphs[row * columns + column]
|
||||||
|
charBuffer[0] = glyph
|
||||||
|
val x = column * charWidth
|
||||||
|
val y = row * charHeight + baselineOffset
|
||||||
|
canvas.drawText(charBuffer, 0, 1, x, y, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StreamState(
|
||||||
|
var headRow: Float,
|
||||||
|
var length: Int,
|
||||||
|
var speedRps: Float,
|
||||||
|
var pauseSec: Float,
|
||||||
|
) {
|
||||||
|
fun reset(rows: Int, style: MatrixStyle, random: Random) {
|
||||||
|
val randomOffset = random.nextFloat() * rows
|
||||||
|
headRow = -randomOffset
|
||||||
|
length = random.nextInt(
|
||||||
|
from = style.minStreamLength.coerceAtLeast(2),
|
||||||
|
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||||
|
)
|
||||||
|
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps
|
||||||
|
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||||
|
style.resetPauseSec.start
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun random(rows: Int, style: MatrixStyle, random: Random): StreamState {
|
||||||
|
val length = random.nextInt(
|
||||||
|
from = style.minStreamLength.coerceAtLeast(2),
|
||||||
|
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||||
|
)
|
||||||
|
return StreamState(
|
||||||
|
headRow = -random.nextFloat() * rows,
|
||||||
|
length = length,
|
||||||
|
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps,
|
||||||
|
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||||
|
style.resetPauseSec.start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,3 +38,33 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object RadarDestination : NavKey
|
||||||
|
|
||||||
|
interface IDeeplinkMatcher {
|
||||||
|
fun match(deeplink: String): NavKey?
|
||||||
|
}
|
||||||
|
|
||||||
|
object PassDetailsMatcher : IDeeplinkMatcher {
|
||||||
|
val passDetailsRegex = """https://github.com/rt-bishop/Look4Sat/passes/(.*)""".toRegex()
|
||||||
|
|
||||||
|
override fun match(deeplink: String): NavKey? {
|
||||||
|
val passMatch = passDetailsRegex.find(deeplink)
|
||||||
|
passMatch?.let { match ->
|
||||||
|
val passId = match.groupValues[1]
|
||||||
|
if (passId.isNotEmpty()) return RadarDestination
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes) {
|
||||||
|
|
||||||
|
private val matchers: List<IDeeplinkMatcher> = listOf(PassDetailsMatcher)
|
||||||
|
|
||||||
|
fun resolve(deeplink: String): NavKey {
|
||||||
|
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
||||||
|
return fallbackDestination
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="15"
|
||||||
|
android:viewportHeight="15">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
||||||
|
</vector>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -13,12 +13,16 @@
|
|||||||
<string name="nav_prefs">Ajustes</string>
|
<string name="nav_prefs">Ajustes</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Tipo: %s</string>
|
<string name="sat_type_hint">Modos: %s</string>
|
||||||
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
<string name="sat_type_title">Seleccionar modos</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>
|
||||||
@@ -26,13 +30,16 @@
|
|||||||
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 tipos.</string>
|
interesen mediante la búsqueda y el selector de modos.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
<string name="pass_filter_title">Filtrar pases</string>
|
||||||
<string name="pass_filter_elev">Elevación mínima</string>
|
<string name="pass_filter_elev">Elevación mínima</string>
|
||||||
<string name="pass_filter_hours">Horas por delante</string>
|
<string name="pass_filter_hours">Tiempo por delante</string>
|
||||||
<string name="pass_modes_title">Tipo de modulación</string>
|
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||||
|
<string name="pass_modes_title">Seleccionar modos</string>
|
||||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -93,11 +100,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">Ajustar ubicaicón de la estación</string>
|
<string name="prefs_station_title">Ubicación de la estación</string>
|
||||||
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
<string name="prefs_station_lat_text">Latitud de estación</string>
|
||||||
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
<string name="prefs_station_lon_text">Longitud de estación</string>
|
||||||
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
<string name="prefs_locator_title">Localizador QTH</string>
|
||||||
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
<string name="prefs_locator_text">Posición por localizador</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>
|
||||||
@@ -107,37 +114,57 @@
|
|||||||
<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">Custom data sources</string>
|
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Salida de datos</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Red</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_net_title">Salida de datos de red</string>
|
<string name="nav_radiocontrol">Control de radio</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="rc_settings_title">Control CAT</string>
|
||||||
|
<string name="rc_radio_model">Modelo de radio</string>
|
||||||
|
<string name="rc_tx_device_hint">Dirección BT TX</string>
|
||||||
|
<string name="rc_rx_device_hint">Dirección BT RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Nombre radio TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Nombre radio RX</string>
|
||||||
|
<string name="rc_enable_switch">Activar CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Salida de red</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
<string name="prefs_net_rotator_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">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
<string name="prefs_net_frequency_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 por Bluetooth</string>
|
<string name="prefs_bt_title">Salida Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
|
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
||||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||||
|
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Otros ajustes</string>
|
<string name="prefs_other_title">Otros</string>
|
||||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Media %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Alta > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
||||||
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
||||||
|
|||||||
@@ -13,25 +13,32 @@
|
|||||||
<string name="nav_prefs">Настройки</string>
|
<string name="nav_prefs">Настройки</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Тип: %s</string>
|
<string name="sat_type_hint">Режимы: %s</string>
|
||||||
<string name="sat_type_title">Выберите тип спутника</string>
|
<string name="sat_type_title">Выберите режимы</string>
|
||||||
<string name="sat_search_hint">Id - Название</string>
|
<string name="sat_search_hint">Id - Название</string>
|
||||||
<string name="sat_search_clear">Очистить</string>
|
<string name="sat_search_clear">Очистить</string>
|
||||||
<string name="sat_clear_all">Очистить</string>
|
<string name="sat_clear_all">Очистить</string>
|
||||||
<string name="sat_select_all">Выбрать</string>
|
<string name="sat_select_all">Выбрать</string>
|
||||||
|
<string name="sat_group_selected">Выбранные</string>
|
||||||
|
<string name="sat_group_available">Доступные</string>
|
||||||
|
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">Доступные (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||||
<string name="sat_warning_title">Внимание\!</string>
|
<string name="sat_warning_title">Внимание\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
В этом приложении перечислено более 9000 спутников.
|
В этом приложении перечислено более 9000 спутников.
|
||||||
Отслеживать их все одновременно бессмысленно.
|
Отслеживать их все одновременно бессмысленно.
|
||||||
\n\nВсегда старайтесь сузить список до тех,
|
\n\nВсегда старайтесь сузить список до тех,
|
||||||
которые вас интересуют, используя поиск и селектор типов.</string>
|
которые вас интересуют, используя поиск и выбор режимов.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||||
<string name="pass_filter_hours">Количество часов</string>
|
<string name="pass_filter_hours">Период расчёта</string>
|
||||||
<string name="pass_modes_title">Выберите тип модуляции</string>
|
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||||
|
<string name="pass_modes_title">Выберите режимы</string>
|
||||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||||
<string name="pass_altitude">Высота: %d км</string>
|
<string name="pass_altitude">Высота: %d км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -92,11 +99,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>
|
||||||
@@ -106,16 +113,30 @@
|
|||||||
<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">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers 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">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Вывод сетевых данных</string>
|
<string name="nav_radiocontrol">Управление радио</string>
|
||||||
|
|
||||||
|
<string name="rc_settings_title">CAT управление</string>
|
||||||
|
<string name="rc_radio_model">Модель радио</string>
|
||||||
|
<string name="rc_tx_device_hint">BT адрес TX</string>
|
||||||
|
<string name="rc_rx_device_hint">BT адрес RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Имя радио TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Имя радио RX</string>
|
||||||
|
<string name="rc_enable_switch">Включить CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Сетевой вывод</string>
|
||||||
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
<string name="prefs_net_rotator_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>
|
||||||
@@ -123,20 +144,27 @@
|
|||||||
<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_other_title">Другие настройки</string>
|
<string name="prefs_other_title">Прочее</string>
|
||||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Средне %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Высоко > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
||||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
||||||
|
|||||||
@@ -13,26 +13,33 @@
|
|||||||
<string name="nav_prefs">පසුතල</string>
|
<string name="nav_prefs">පසුතල</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">වර්ගය: %s</string>
|
<string name="sat_type_hint">මාදිලි: %s</string>
|
||||||
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
||||||
<string name="sat_search_hint">Id - නම</string>
|
<string name="sat_search_hint">Id - නම</string>
|
||||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||||
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
||||||
|
<string name="sat_group_selected">තෝරාගත්</string>
|
||||||
|
<string name="sat_group_available">තිබෙන</string>
|
||||||
|
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">
|
<string name="sat_empty_list_message">
|
||||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||||
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
||||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
||||||
<string name="pass_filter_hours">ඉදිරි පැය</string>
|
<string name="pass_filter_hours">ඉදිරි කාලය</string>
|
||||||
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||||
|
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||||
|
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
|
||||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -93,11 +100,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>
|
||||||
@@ -107,37 +114,57 @@
|
|||||||
<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">Custom data sources</string>
|
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">දත්ත ප්රතිදානය</string>
|
||||||
<string name="prefs_net_output">Network</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="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">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_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">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</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">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</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">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</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_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">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">මධ්ය %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">ඉහළ > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
||||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
||||||
|
|||||||
@@ -14,26 +14,33 @@
|
|||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
<string name="sat_type_hint">Modlar: %s</string>
|
||||||
<string name="sat_type_title">Uydu türü seçin</string>
|
<string name="sat_type_title">Modları 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 tür seçiciyi kullanın.</string>
|
daraltmak için arama ve mod seçiciyi kullanın.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||||
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
||||||
<string name="pass_filter_hours">Gösterilecek saat aralığı</string>
|
<string name="pass_filter_hours">Gösterilecek zaman</string>
|
||||||
|
<string name="pass_filter_aos_time">AOS aralığı</string>
|
||||||
|
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
<string name="pass_modes_title">Modları seçin</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_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>
|
||||||
@@ -124,11 +131,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
||||||
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
||||||
<string name="prefs_station_title">İstasyon konumu ayarları</string>
|
<string name="prefs_station_title">İstasyon konumu</string>
|
||||||
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
|
<string name="prefs_station_lat_text">İstasyon enlemi</string>
|
||||||
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
|
<string name="prefs_station_lon_text">İstasyon boylamı</string>
|
||||||
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
|
<string name="prefs_locator_title">QTH konumlandırıcı</string>
|
||||||
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
|
<string name="prefs_locator_text">Konumu lokatörle ayarla</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>
|
||||||
@@ -138,28 +145,30 @@
|
|||||||
<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 veri kaynakları</string>
|
<string name="prefs_data_sources_title">Özel kaynaklar</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Veri aktarımı</string>
|
<string name="prefs_data_output_title">Veri çıkışı</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 Radyo Kontrolü</string>
|
<string name="rc_settings_title">CAT 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 Radyo BT Adresi</string>
|
<string name="rc_tx_device_hint">TX BT adresi</string>
|
||||||
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
|
<string name="rc_rx_device_hint">RX BT adresi</string>
|
||||||
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
<string name="rc_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 Kontrolünü Etkinleştir</string>
|
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Ağ veri çıkışı</string>
|
<string name="prefs_net_title">Ağ çıkışı</string>
|
||||||
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
<string name="prefs_net_rotator_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>
|
||||||
@@ -167,20 +176,27 @@
|
|||||||
<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 veri çıkışı</string>
|
<string name="prefs_bt_title">Bluetooth çıkışı</string>
|
||||||
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
<string name="prefs_bt_rotator_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">Veri biçimi</string>
|
<string name="prefs_bt_rotator_output_hint">Biçim</string>
|
||||||
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
|
<string name="prefs_bt_frequency_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">Veri biçimi</string>
|
<string name="prefs_bt_frequency_output_hint">Biçim</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
|
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
<string name="prefs_other_title">Diğer</string>
|
||||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
<string name="prefs_other_switch_utc">UTC saati</string>
|
||||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
<string name="prefs_other_switch_sweep">Radar taraması</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_sensors">Sensör</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Yüksek > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Teşekkürler</string>
|
<string name="prefs_outro_title">Teşekkürler</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
@@ -13,12 +13,16 @@
|
|||||||
<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>
|
||||||
@@ -26,13 +30,16 @@
|
|||||||
У цьому додатку перелічено понад 9000 супутників.
|
У цьому додатку перелічено понад 9000 супутників.
|
||||||
Немає сенсу відстежувати їх усі одночасно.
|
Немає сенсу відстежувати їх усі одночасно.
|
||||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||||
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||||
<string name="pass_filter_elev">Мінімальне піднесення</string>
|
<string name="pass_filter_elev">Мінімальне піднесень</string>
|
||||||
<string name="pass_filter_hours">Кількість годин</string>
|
<string name="pass_filter_hours">Час вперед</string>
|
||||||
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||||
|
<string name="pass_modes_title">Виберіть режими</string>
|
||||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||||
<string name="pass_altitude">Висота: %.0f км</string>
|
<string name="pass_altitude">Висота: %.0f км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -66,7 +73,7 @@
|
|||||||
<string name="map_prev">Попередній</string>
|
<string name="map_prev">Попередній</string>
|
||||||
<string name="map_next">Наступний</string>
|
<string name="map_next">Наступний</string>
|
||||||
<string name="map_azimuth">Азимут: %.1f°</string>
|
<string name="map_azimuth">Азимут: %.1f°</string>
|
||||||
<string name="map_elevation">Піднесення: %.1f°</string>
|
<string name="map_elevation">Піднесень: %.1f°</string>
|
||||||
<string name="map_altitude">Висота: %.0f км</string>
|
<string name="map_altitude">Висота: %.0f км</string>
|
||||||
<string name="map_distance">Дистанція: %.0f км</string>
|
<string name="map_distance">Дистанція: %.0f км</string>
|
||||||
<string name="map_latitude">Широта: %.1f°</string>
|
<string name="map_latitude">Широта: %.1f°</string>
|
||||||
@@ -93,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||||
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
<string name="prefs_station_title">Позиція станції</string>
|
||||||
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
<string name="prefs_station_lat_text">Широта станції</string>
|
||||||
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
<string name="prefs_station_lon_text">Довгота станції</string>
|
||||||
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
<string name="prefs_locator_title">Локатор QTH</string>
|
||||||
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
<string name="prefs_locator_text">Позиція за локатором</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Супутникові дані</string>
|
<string name="prefs_data_title">Супутникові дані</string>
|
||||||
<string name="prefs_data_entries">Супутників: %s</string>
|
<string name="prefs_data_entries">Супутників: %s</string>
|
||||||
@@ -107,37 +114,57 @@
|
|||||||
<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">Custom data sources</string>
|
<string name="prefs_data_sources_title">Власні джерела</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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">Data output</string>
|
<string name="prefs_data_output_title">Вивід даних</string>
|
||||||
<string name="prefs_net_output">Network</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="prefs_net_title">Вивід мережевих даних</string>
|
<string name="nav_radiocontrol">Керування радіо</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="rc_settings_title">CAT керування</string>
|
||||||
|
<string name="rc_radio_model">Модель радіо</string>
|
||||||
|
<string name="rc_tx_device_hint">BT адреса TX</string>
|
||||||
|
<string name="rc_rx_device_hint">BT адреса RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Назва радіо TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Назва радіо RX</string>
|
||||||
|
<string name="rc_enable_switch">Увімкнути CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Мережевий вивід</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
<string name="prefs_net_rotator_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">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_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">Enable rotation output</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">Enable frequency output</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_other_title">Інші налаштування</string>
|
<string name="prefs_other_title">Інше</string>
|
||||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
<string name="prefs_other_switch_sensors">Сенсори</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Середньо %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Високо > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
||||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
||||||
|
|||||||
@@ -13,21 +13,28 @@
|
|||||||
<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>
|
||||||
<string name="pass_filter_elev">仰角值</string>
|
<string name="pass_filter_elev">仰角值</string>
|
||||||
<string name="pass_filter_hours">小时数</string>
|
<string name="pass_filter_hours">提前时间</string>
|
||||||
<string name="pass_modes_title">选择调制类型</string>
|
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||||
|
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||||
|
<string name="pass_modes_title">选择模式</string>
|
||||||
<string name="pass_elevation">仰角:%.1f°</string>
|
<string name="pass_elevation">仰角:%.1f°</string>
|
||||||
<string name="pass_altitude">高度:%d km</string>
|
<string name="pass_altitude">高度:%d km</string>
|
||||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||||
@@ -53,6 +60,9 @@
|
|||||||
<string name="radar_string_no">否</string>
|
<string name="radar_string_no">否</string>
|
||||||
<string name="radar_string_yes">是</string>
|
<string name="radar_string_yes">是</string>
|
||||||
<string name="radar_visible">日照中</string>
|
<string name="radar_visible">日照中</string>
|
||||||
|
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
@@ -85,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">通过 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>
|
||||||
@@ -99,16 +109,29 @@
|
|||||||
<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">自定义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_net_title">网络数据输出</string>
|
<string name="nav_radiocontrol">电台控制</string>
|
||||||
|
<string name="rc_settings_title">CAT 控制</string>
|
||||||
|
<string name="rc_radio_model">电台型号</string>
|
||||||
|
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
|
||||||
|
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
|
||||||
|
<string name="rc_tx_name_hint">TX 电台名称</string>
|
||||||
|
<string name="rc_rx_name_hint">RX 电台名称</string>
|
||||||
|
<string name="rc_enable_switch">启用 CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">网络输出</string>
|
||||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_net_rotator_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>
|
||||||
@@ -116,7 +139,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>
|
||||||
@@ -124,13 +147,19 @@
|
|||||||
<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">请检查蓝牙权限</string>
|
<string name="prefs_bt_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_low">低 < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">我要感谢:</string>
|
<string name="prefs_outro_title">我要感谢:</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
@@ -14,27 +14,33 @@
|
|||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Type: %s</string>
|
<string name="sat_type_hint">Modes: %s</string>
|
||||||
<string name="sat_type_title">Select satellite type</string>
|
<string name="sat_type_title">Select modes</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 types selector.</string>
|
you\'re interested in via search and the modes selector.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filter passes</string>
|
<string name="pass_filter_title">Filter passes</string>
|
||||||
<string name="pass_filter_elev">Minimal elevation</string>
|
<string name="pass_filter_elev">Minimal elevation</string>
|
||||||
<string name="pass_filter_hours">Hours ahead</string>
|
<string name="pass_filter_hours">Time ahead</string>
|
||||||
|
<string name="pass_filter_aos_time">AOS window</string>
|
||||||
|
<string name="pass_filter_invert_time">Invert AOS window</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Select modulation type</string>
|
<string name="pass_modes_title">Select modes</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>
|
||||||
@@ -49,9 +55,16 @@
|
|||||||
\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">
|
||||||
* Merged RadarScreen and RadioControlScreen functionality
|
* Added small tweaks to Sources and strings. Added official ISS source (#225)
|
||||||
\n\n* Added SSTV image decoding functionality to RadarScreen
|
\n* Implemented AOS window and elevation highlight filters (#226)
|
||||||
\n\n* Added colored elevation and decay check to satellite passes
|
\n* Added cleartext traffic support for custom TLE URLs (#227)
|
||||||
|
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
|
||||||
|
\n* Added support for Icom IC-705 CAT by ruilvo (#229)
|
||||||
|
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
|
||||||
|
\n* Localized pass date and time formats for Chinese by atsunatsu (#231)
|
||||||
|
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
|
||||||
|
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
|
||||||
|
\n* Replaced the types selection dialog with modes selection to avoid confusion
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -79,6 +92,9 @@
|
|||||||
<string name="radar_string_no">No</string>
|
<string name="radar_string_no">No</string>
|
||||||
<string name="radar_string_yes">Yes</string>
|
<string name="radar_string_yes">Yes</string>
|
||||||
<string name="radar_visible">Visible</string>
|
<string name="radar_visible">Visible</string>
|
||||||
|
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
@@ -123,11 +139,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
||||||
<string name="prefs_loc_success">Position updated successfully</string>
|
<string name="prefs_loc_success">Position updated successfully</string>
|
||||||
<string name="prefs_station_title">Station position settings</string>
|
<string name="prefs_station_title">Station position</string>
|
||||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
<string name="prefs_station_lat_text">Station latitude</string>
|
||||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
<string name="prefs_station_lon_text">Station longitude</string>
|
||||||
<string name="prefs_locator_title">QTH locator settings</string>
|
<string name="prefs_locator_title">QTH locator</string>
|
||||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
<string name="prefs_locator_text">Set position via 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>
|
||||||
@@ -137,10 +153,12 @@
|
|||||||
<string name="prefs_data_clear">Clear</string>
|
<string name="prefs_data_clear">Clear</string>
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
||||||
<string name="prefs_data_update_success">Update completed successfully</string>
|
<string name="prefs_data_update_success">Update completed successfully</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Custom sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
@@ -150,15 +168,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 Radio Control</string>
|
<string name="rc_settings_title">CAT 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 Radio BT Address</string>
|
<string name="rc_tx_device_hint">TX BT address</string>
|
||||||
<string name="rc_rx_device_hint">RX Radio BT Address</string>
|
<string name="rc_rx_device_hint">RX BT address</string>
|
||||||
<string name="rc_tx_name_hint">TX Radio Name</string>
|
<string name="rc_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 Control</string>
|
<string name="rc_enable_switch">Enable CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Network data output</string>
|
<string name="prefs_net_title">Network output</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_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>
|
||||||
@@ -166,21 +184,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 data output</string>
|
<string name="prefs_bt_title">Bluetooth output</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_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">Data format</string>
|
<string name="prefs_bt_rotator_output_hint">Format</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_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">Data format</string>
|
<string name="prefs_bt_frequency_output_hint">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_other_title">Other settings</string>
|
<string name="prefs_other_title">Other</string>
|
||||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
<string name="prefs_other_switch_utc">UTC time</string>
|
||||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
<string name="prefs_other_switch_update">Auto-update data</string>
|
||||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
<string name="prefs_other_switch_sensors">Use sensors</string>
|
||||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
<string name="prefs_other_switch_night_mode">Night filter</string>
|
||||||
|
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">High > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DeeplinkResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsDefaultDestination() {
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
val result = deeplinkResolver.resolve("/deeplink")
|
||||||
|
assert(result == Screen.Passes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsRadarDestination() {
|
||||||
|
val passId = "some-pass-id"
|
||||||
|
val passDeeplink = "https://github.com/rt-bishop/Look4Sat/passes/$passId"
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
val result = deeplinkResolver.resolve(passDeeplink)
|
||||||
|
assert(result == RadarDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
* Merged RadarScreen and RadioControlScreen functionality
|
* Added ARISS source, tweaked SSTV sensitivity #225
|
||||||
* Added SSTV image decoding functionality to RadarScreen
|
* Implemented AOS window and elev filter #226
|
||||||
* Added colored elevation and decay check to satellite passes
|
* 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
|
||||||
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.clipLat
|
import com.rtbishop.look4sat.core.domain.utility.clipLat
|
||||||
import com.rtbishop.look4sat.core.domain.utility.clipLon
|
import com.rtbishop.look4sat.core.domain.utility.clipLon
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
@@ -166,9 +167,7 @@ class MapViewModel(
|
|||||||
var localSelectedPos: OrbitalPos? = null
|
var localSelectedPos: OrbitalPos? = null
|
||||||
for (satellite in chunk) {
|
for (satellite in chunk) {
|
||||||
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
localPositions.add(satellite to satPos.toMapGeoPos())
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
localPositions.add(satellite to GeoPos(osmLat, osmLon))
|
|
||||||
if (satellite === selected) {
|
if (satellite === selected) {
|
||||||
localSelectedPos = satPos
|
localSelectedPos = satPos
|
||||||
}
|
}
|
||||||
@@ -242,9 +241,7 @@ class MapViewModel(
|
|||||||
}
|
}
|
||||||
val azimuth = satPos.azimuth.toDegrees()
|
val azimuth = satPos.azimuth.toDegrees()
|
||||||
val elevation = satPos.elevation.toDegrees()
|
val elevation = satPos.elevation.toDegrees()
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
val osmPos = satPos.toMapGeoPos()
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
val osmPos = GeoPos(osmLat, osmLon)
|
|
||||||
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
||||||
val velocity = satPos.getOrbitalVelocity()
|
val velocity = satPos.getOrbitalVelocity()
|
||||||
val phase = satPos.phase.toDegrees()
|
val phase = satPos.phase.toDegrees()
|
||||||
@@ -274,18 +271,16 @@ class MapViewModel(
|
|||||||
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
||||||
var oldLongitude = 0.0
|
var oldLongitude = 0.0
|
||||||
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
val currentPosition = satPos.toMapGeoPos()
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
val currentPosition = GeoPos(osmLat, osmLon)
|
|
||||||
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
||||||
// adding left terminal position
|
// adding left terminal position
|
||||||
currentTrack.add(GeoPos(osmLat, -180.0))
|
currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||||
satTracks.add(finishedTrack)
|
satTracks.add(finishedTrack)
|
||||||
currentTrack.clear()
|
currentTrack.clear()
|
||||||
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
||||||
// adding right terminal position
|
// adding right terminal position
|
||||||
currentTrack.add(GeoPos(osmLat, 180.0))
|
currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||||
satTracks.add(finishedTrack)
|
satTracks.add(finishedTrack)
|
||||||
currentTrack.clear()
|
currentTrack.clear()
|
||||||
|
|||||||
+224
-27
@@ -21,8 +21,11 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
@@ -31,6 +34,7 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RangeSlider
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -55,44 +59,88 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
|||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
private val allModes = listOf(
|
import kotlin.math.roundToInt
|
||||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
|
||||||
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
|
|
||||||
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
|
|
||||||
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
|
|
||||||
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
|
|
||||||
)
|
|
||||||
|
|
||||||
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
||||||
|
private const val dayMinutes = 24 * 60
|
||||||
|
private const val minuteStep = 15
|
||||||
|
private const val endOfDayMinute = dayMinutes - 1
|
||||||
|
private const val quarterHourSlots = dayMinutes / minuteStep
|
||||||
|
|
||||||
|
data class PassFilterParams(
|
||||||
|
val hours: Int,
|
||||||
|
val elevation: Double,
|
||||||
|
val lowElevation: Double,
|
||||||
|
val highElevation: Double,
|
||||||
|
val aosStartMinute: Int,
|
||||||
|
val aosEndMinute: Int,
|
||||||
|
val invertAosTimeWindow: Boolean,
|
||||||
|
val showDeepSpace: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesDialogPreview() {
|
private fun PassesDialogPreview() {
|
||||||
MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } }
|
MainTheme {
|
||||||
|
PassesFilterDialog(
|
||||||
|
hours = 24,
|
||||||
|
elevation = 16.0,
|
||||||
|
lowElevation = 16.0,
|
||||||
|
highElevation = 65.0,
|
||||||
|
aosStartMinute = 0,
|
||||||
|
aosEndMinute = 23 * 60 + 59,
|
||||||
|
invertAosTimeWindow = false,
|
||||||
|
showDeepSpace = true,
|
||||||
|
cancel = {},
|
||||||
|
accept = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PassesDialog(
|
internal fun PassesFilterDialog(
|
||||||
hours: Int,
|
hours: Int,
|
||||||
elevation: Double,
|
elevation: Double,
|
||||||
|
lowElevation: Double,
|
||||||
|
highElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
showDeepSpace: Boolean,
|
showDeepSpace: Boolean,
|
||||||
cancel: () -> Unit,
|
cancel: () -> Unit,
|
||||||
accept: (Int, Double, Boolean) -> Unit
|
accept: (PassFilterParams) -> Unit
|
||||||
) {
|
) {
|
||||||
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
||||||
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
||||||
|
val highlightBounds = 0f..90f
|
||||||
|
var highlightRange by remember(lowElevation, highElevation) {
|
||||||
|
mutableStateOf(lowElevation.toFloat()..highElevation.toFloat())
|
||||||
|
}
|
||||||
|
var aosRangeValue by remember {
|
||||||
|
mutableStateOf(minuteToSlot(aosStartMinute).toFloat()..minuteToSlot(aosEndMinute).toFloat())
|
||||||
|
}
|
||||||
|
var invertedAosRange by remember { mutableStateOf(invertAosTimeWindow) }
|
||||||
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
|
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() }
|
accept(
|
||||||
|
PassFilterParams(
|
||||||
|
hours = hourSteps[hoursIndex.intValue],
|
||||||
|
elevation = elevationValueNew.doubleValue,
|
||||||
|
lowElevation = highlightRange.start.roundToInt().toDouble(),
|
||||||
|
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
|
||||||
|
aosStartMinute = slotToMinute(aosRangeValue.start),
|
||||||
|
aosEndMinute = slotToMinute(aosRangeValue.endInclusive),
|
||||||
|
invertAosTimeWindow = invertedAosRange,
|
||||||
|
showDeepSpace = deepSpaceEnabled
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cancel()
|
||||||
}
|
}
|
||||||
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
ToggleRow(
|
|
||||||
title = stringResource(R.string.pass_filter_deep_space),
|
|
||||||
checked = deepSpaceEnabled,
|
|
||||||
onCheckedChange = { deepSpaceEnabled = it }
|
|
||||||
)
|
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_elev),
|
title = stringResource(R.string.pass_filter_elev),
|
||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
@@ -104,24 +152,74 @@ internal fun PassesDialog(
|
|||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_hours),
|
title = stringResource(R.string.pass_filter_hours),
|
||||||
value = hoursIndex.intValue.toDouble(),
|
value = hoursIndex.intValue.toDouble(),
|
||||||
displayValue = "${hourSteps[hoursIndex.intValue]}h",
|
displayValue = formatHoursLabel(hourSteps[hoursIndex.intValue]),
|
||||||
valueResId = R.drawable.ic_clock,
|
valueResId = R.drawable.ic_clock,
|
||||||
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
||||||
steps = hourSteps.size - 2
|
steps = hourSteps.size - 2
|
||||||
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.pass_filter_deep_space),
|
||||||
|
checked = deepSpaceEnabled,
|
||||||
|
onCheckedChange = { deepSpaceEnabled = it }
|
||||||
|
)
|
||||||
|
ElevationColorsRangeSliderRow(
|
||||||
|
title = stringResource(R.string.prefs_highlight_title),
|
||||||
|
range = highlightRange,
|
||||||
|
valueRange = highlightBounds
|
||||||
|
) { highlightRange = it }
|
||||||
|
TimeRangeSliderRow(
|
||||||
|
title = stringResource(R.string.pass_filter_aos_time),
|
||||||
|
range = aosRangeValue,
|
||||||
|
displayValue = formatTimeRange(aosRangeValue, invertedAosRange),
|
||||||
|
valueResId = R.drawable.ic_clock,
|
||||||
|
valueRange = 0f..quarterHourSlots.toFloat(),
|
||||||
|
) { aosRangeValue = it }
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.pass_filter_invert_time),
|
||||||
|
checked = invertedAosRange,
|
||||||
|
onCheckedChange = { invertedAosRange = it }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun slotToMinute(value: Float): Int {
|
||||||
|
val slot = value.roundToInt().coerceIn(0, quarterHourSlots)
|
||||||
|
return if (slot == quarterHourSlots) endOfDayMinute else slot * minuteStep
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun minuteToSlot(minute: Int): Int {
|
||||||
|
if (minute >= endOfDayMinute) return quarterHourSlots
|
||||||
|
return ((minute + minuteStep / 2) / minuteStep).coerceIn(0, quarterHourSlots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMinuteOfDay(minute: Int): String {
|
||||||
|
val hourPart = minute / 60
|
||||||
|
val minutePart = minute % 60
|
||||||
|
return "%02d:%02d".format(hourPart, minutePart)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatHoursLabel(hours: Int): String {
|
||||||
|
if (hours < 24) return "${hours}h"
|
||||||
|
val days = hours / 24
|
||||||
|
val remainder = hours % 24
|
||||||
|
return if (remainder == 0) "${days}d" else "${days}d ${remainder}h"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTimeRange(
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
inverted: Boolean
|
||||||
|
): String {
|
||||||
|
val start = formatMinuteOfDay(slotToMinute(range.start))
|
||||||
|
val end = formatMinuteOfDay(slotToMinute(range.endInclusive))
|
||||||
|
return if (inverted) "$end - $start" else "$start - $end"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SliderRow(
|
private fun SliderSection(
|
||||||
title: String,
|
title: String,
|
||||||
value: Double,
|
trailingContent: @Composable () -> Unit,
|
||||||
displayValue: String,
|
sliderContent: @Composable () -> Unit
|
||||||
valueResId: Int,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
|
||||||
steps: Int = 0,
|
|
||||||
accentColor: Color = MaterialTheme.colorScheme.primary,
|
|
||||||
onChange: (Float) -> Unit
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -138,6 +236,26 @@ private fun SliderRow(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
trailingContent()
|
||||||
|
}
|
||||||
|
sliderContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SliderRow(
|
||||||
|
title: String,
|
||||||
|
value: Double,
|
||||||
|
displayValue: String,
|
||||||
|
valueResId: Int,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
steps: Int = 0,
|
||||||
|
accentColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
onChange: (Float) -> Unit
|
||||||
|
) {
|
||||||
|
SliderSection(
|
||||||
|
title = title,
|
||||||
|
trailingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = valueResId),
|
painter = painterResource(id = valueResId),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -151,10 +269,89 @@ private fun SliderRow(
|
|||||||
color = accentColor
|
color = accentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimeRangeSliderRow(
|
||||||
|
title: String,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
displayValue: String,
|
||||||
|
valueResId: Int,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
onChange: (ClosedFloatingPointRange<Float>) -> Unit
|
||||||
|
) {
|
||||||
|
SliderSection(
|
||||||
|
title = title,
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = valueResId),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = displayValue,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
RangeSlider(
|
||||||
|
value = range,
|
||||||
|
onValueChange = { onChange(it.start..it.endInclusive) },
|
||||||
|
valueRange = valueRange,
|
||||||
|
steps = 0,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ElevationColorsRangeSliderRow(
|
||||||
|
title: String,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
onChange: (ClosedFloatingPointRange<Float>) -> Unit
|
||||||
|
) {
|
||||||
|
val low = range.start.roundToInt()
|
||||||
|
val high = range.endInclusive.roundToInt()
|
||||||
|
SliderSection(
|
||||||
|
title = title,
|
||||||
|
trailingContent = {
|
||||||
|
Text(
|
||||||
|
text = "$low°",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = ElevationLowColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "..",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$high°",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = ElevationHighColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
RangeSlider(
|
||||||
|
value = range,
|
||||||
|
onValueChange = { onChange(it.start..it.endInclusive) },
|
||||||
|
valueRange = valueRange,
|
||||||
|
steps = 0,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadiosDialogPreview() {
|
private fun RadiosDialogPreview() {
|
||||||
@@ -177,7 +374,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(allModes) { index, item ->
|
itemsIndexed(Sources.satelliteModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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
|
||||||
@@ -67,11 +68,11 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
@@ -99,14 +100,31 @@ private fun PassesScreen(
|
|||||||
navigateToRadar: (Int, Long) -> Unit
|
navigateToRadar: (Int, Long) -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isPassesDialogShown) {
|
if (uiState.isPassesDialogShown) {
|
||||||
PassesDialog(
|
PassesFilterDialog(
|
||||||
hours = uiState.hours,
|
hours = uiState.hours,
|
||||||
elevation = uiState.elevation,
|
elevation = uiState.elevation,
|
||||||
|
lowElevation = uiState.lowElevation,
|
||||||
|
highElevation = uiState.highElevation,
|
||||||
|
aosStartMinute = uiState.aosStartMinute,
|
||||||
|
aosEndMinute = uiState.aosEndMinute,
|
||||||
|
invertAosTimeWindow = uiState.invertAosTimeWindow,
|
||||||
showDeepSpace = uiState.showDeepSpace,
|
showDeepSpace = uiState.showDeepSpace,
|
||||||
cancel = { onAction(PassesAction.TogglePassesDialog) }
|
cancel = { onAction(PassesAction.TogglePassesDialog) },
|
||||||
) { hours, elevation, showDeepSpace ->
|
accept = { params ->
|
||||||
onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
|
onAction(
|
||||||
}
|
PassesAction.FilterPasses(
|
||||||
|
hoursAhead = params.hours,
|
||||||
|
minElevation = params.elevation,
|
||||||
|
lowElevation = params.lowElevation,
|
||||||
|
highElevation = params.highElevation,
|
||||||
|
aosStartMinute = params.aosStartMinute,
|
||||||
|
aosEndMinute = params.aosEndMinute,
|
||||||
|
invertAosTimeWindow = params.invertAosTimeWindow,
|
||||||
|
showDeepSpace = params.showDeepSpace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (uiState.isRadiosDialogShown) {
|
if (uiState.isRadiosDialogShown) {
|
||||||
RadiosDialog(
|
RadiosDialog(
|
||||||
@@ -117,11 +135,20 @@ private fun PassesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWhatsNew) {
|
if (uiState.shouldSeeWhatsNew) {
|
||||||
InfoDialog(
|
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
||||||
|
SharedDialog(
|
||||||
title = stringResource(R.string.pass_whatsnew_title),
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
text = stringResource(R.string.pass_whatsnew_message)
|
onDismissRequest = dismiss,
|
||||||
) {
|
onAccept = dismiss,
|
||||||
onAction(PassesAction.DismissWhatsNew)
|
titleFontSize = 18
|
||||||
|
) { padding ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pass_whatsnew_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
@@ -129,7 +156,7 @@ private fun PassesScreen(
|
|||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVerticalLayout,
|
isVerticalLayout = isVerticalLayout,
|
||||||
startAction = {
|
startAction = {
|
||||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||||
@@ -138,7 +165,7 @@ private fun PassesScreen(
|
|||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -265,6 +292,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeepSpacePassPreview() {
|
private fun DeepSpacePassPreview() {
|
||||||
@@ -297,7 +329,7 @@ private fun PassItem(
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ data class PassesState(
|
|||||||
val isNextTimeAos: Boolean = true,
|
val isNextTimeAos: Boolean = true,
|
||||||
val hours: Int = 24,
|
val hours: Int = 24,
|
||||||
val elevation: Double = 16.0,
|
val elevation: Double = 16.0,
|
||||||
|
val lowElevation: Double = 16.0,
|
||||||
|
val highElevation: Double = 65.0,
|
||||||
|
val aosStartMinute: Int = 0,
|
||||||
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
|
val invertAosTimeWindow: Boolean = false,
|
||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val modes: List<String> = emptyList(),
|
val modes: List<String> = emptyList(),
|
||||||
val itemsList: List<OrbitalPass> = emptyList(),
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
@@ -40,7 +45,16 @@ data class PassesState(
|
|||||||
|
|
||||||
sealed interface PassesAction {
|
sealed interface PassesAction {
|
||||||
data object DismissWhatsNew : PassesAction
|
data object DismissWhatsNew : PassesAction
|
||||||
data class FilterPasses(val hoursAhead: Int, val minElevation: Double, val showDeepSpace: Boolean) : PassesAction
|
data class FilterPasses(
|
||||||
|
val hoursAhead: Int,
|
||||||
|
val minElevation: Double,
|
||||||
|
val lowElevation: Double,
|
||||||
|
val highElevation: Double,
|
||||||
|
val aosStartMinute: Int,
|
||||||
|
val aosEndMinute: Int,
|
||||||
|
val invertAosTimeWindow: Boolean,
|
||||||
|
val showDeepSpace: Boolean
|
||||||
|
) : PassesAction
|
||||||
data class FilterRadios(val modes: List<String>) : PassesAction
|
data class FilterRadios(val modes: List<String>) : PassesAction
|
||||||
data object RefreshPasses : PassesAction
|
data object RefreshPasses : PassesAction
|
||||||
data object TogglePassesDialog : PassesAction
|
data object TogglePassesDialog : PassesAction
|
||||||
|
|||||||
+145
-41
@@ -34,6 +34,9 @@ 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
|
||||||
@@ -41,6 +44,7 @@ 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,
|
||||||
@@ -54,8 +58,13 @@ class PassesViewModel(
|
|||||||
nextPass = defaultPass,
|
nextPass = defaultPass,
|
||||||
hours = settingsRepo.passesSettings.value.hoursAhead,
|
hours = settingsRepo.passesSettings.value.hoursAhead,
|
||||||
elevation = settingsRepo.passesSettings.value.minElevation,
|
elevation = settingsRepo.passesSettings.value.minElevation,
|
||||||
|
lowElevation = settingsRepo.otherSettings.value.lowElevation,
|
||||||
|
highElevation = settingsRepo.otherSettings.value.highElevation,
|
||||||
|
aosStartMinute = settingsRepo.passesSettings.value.aosStartMinute,
|
||||||
|
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
||||||
|
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
||||||
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
||||||
modes = settingsRepo.passesSettings.value.selectedModes,
|
modes = settingsRepo.selectedSatModes.value,
|
||||||
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -71,45 +80,70 @@ class PassesViewModel(
|
|||||||
// React to settings changes: update UTC flag and whatsNew
|
// React to settings changes: update UTC flag and whatsNew
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
_uiState.update {
|
||||||
}
|
it.copy(
|
||||||
}
|
isUtc = settings.stateOfUtc,
|
||||||
// Main tick loop: reacts to new passes, then ticks every second
|
shouldSeeWhatsNew = settings.shouldSeeWhatsNew,
|
||||||
viewModelScope.launch {
|
lowElevation = settings.lowElevation,
|
||||||
satelliteRepo.passes.collectLatest { allPasses ->
|
highElevation = settings.highElevation
|
||||||
while (isActive) {
|
)
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val isUtc = _uiState.value.isUtc
|
|
||||||
val showDeepSpace = _uiState.value.showDeepSpace
|
|
||||||
val filtered = allPasses
|
|
||||||
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
|
||||||
val processed = computePassProgress(filtered, timeNow)
|
|
||||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
|
||||||
val sunTimes = computeSunTimes(processed, isUtc)
|
|
||||||
val grouped = groupPasses(processed, isUtc)
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
itemsList = processed,
|
|
||||||
groupedPasses = grouped,
|
|
||||||
sunTimes = sunTimes,
|
|
||||||
nextPass = nextPass,
|
|
||||||
nextTime = nextTime,
|
|
||||||
isNextTimeAos = isAos
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.selectedSatModes.collectLatest { modes ->
|
||||||
|
_uiState.update { it.copy(modes = modes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
||||||
|
// computations run once per restart, progress/countdown are calculated every second.
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
satelliteRepo.passes,
|
||||||
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
||||||
|
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
||||||
|
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
||||||
|
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
||||||
|
val filtered = if (showDeepSpace) allPasses
|
||||||
|
else allPasses.filter { !it.isDeepSpace }
|
||||||
|
// Expensive: recompute sun times once per items/UTC change, not every second
|
||||||
|
val sunTimes = computeSunTimes(filtered, isUtc)
|
||||||
|
_uiState.update { it.copy(sunTimes = sunTimes) }
|
||||||
|
while (isActive) {
|
||||||
|
val timeNow = System.currentTimeMillis()
|
||||||
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
|
val grouped = groupPasses(processed, isUtc)
|
||||||
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
itemsList = processed,
|
||||||
|
groupedPasses = grouped,
|
||||||
|
nextPass = nextPass,
|
||||||
|
nextTime = nextTime,
|
||||||
|
isNextTimeAos = isAos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: PassesAction) {
|
fun onAction(action: PassesAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
||||||
is PassesAction.FilterPasses ->
|
is PassesAction.FilterPasses ->
|
||||||
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
|
applyFilter(
|
||||||
is PassesAction.FilterRadios ->
|
hoursAhead = action.hoursAhead,
|
||||||
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
|
minElevation = action.minElevation,
|
||||||
|
lowElevation = action.lowElevation,
|
||||||
|
highElevation = action.highElevation,
|
||||||
|
aosStartMinute = action.aosStartMinute,
|
||||||
|
aosEndMinute = action.aosEndMinute,
|
||||||
|
invertAosTimeWindow = action.invertAosTimeWindow,
|
||||||
|
showDeepSpace = action.showDeepSpace
|
||||||
|
)
|
||||||
|
is PassesAction.FilterRadios -> setModesFilter(action.modes)
|
||||||
PassesAction.RefreshPasses -> refreshPasses()
|
PassesAction.RefreshPasses -> refreshPasses()
|
||||||
PassesAction.TogglePassesDialog ->
|
PassesAction.TogglePassesDialog ->
|
||||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
@@ -120,12 +154,28 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
|
||||||
|
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
|
||||||
|
val locale = displayLocale()
|
||||||
|
val pattern = if (locale.language == Locale.CHINESE.language) {
|
||||||
|
"yyyy'年'M'月'd'日' EEEE"
|
||||||
|
} else {
|
||||||
|
"EEE, dd MMM yyyy"
|
||||||
|
}
|
||||||
|
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
|
||||||
|
}
|
||||||
|
|
||||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = dateFormat(tz)
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).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 }) {
|
||||||
@@ -148,7 +198,7 @@ class PassesViewModel(
|
|||||||
|
|
||||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = dateFormat(tz)
|
||||||
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
val deepSpace = passes.filter { it.isDeepSpace }
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
@@ -198,19 +248,73 @@ class PassesViewModel(
|
|||||||
private fun applyFilter(
|
private fun applyFilter(
|
||||||
hoursAhead: Int,
|
hoursAhead: Int,
|
||||||
minElevation: Double,
|
minElevation: Double,
|
||||||
showDeepSpace: Boolean,
|
lowElevation: Double,
|
||||||
modes: List<String>
|
highElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
showDeepSpace: Boolean
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
settingsRepo.setPassesSettings(PassesSettings(showDeepSpace, hoursAhead, minElevation, modes))
|
settingsRepo.setPassesSettings(
|
||||||
|
PassesSettings(
|
||||||
|
showDeepSpace,
|
||||||
|
hoursAhead,
|
||||||
|
minElevation,
|
||||||
|
aosStartMinute,
|
||||||
|
aosEndMinute,
|
||||||
|
invertAosTimeWindow
|
||||||
|
)
|
||||||
|
)
|
||||||
|
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(hours = hoursAhead, elevation = minElevation, showDeepSpace = showDeepSpace, modes = modes)
|
it.copy(
|
||||||
|
hours = hoursAhead,
|
||||||
|
elevation = minElevation,
|
||||||
|
lowElevation = lowElevation,
|
||||||
|
highElevation = highElevation,
|
||||||
|
aosStartMinute = aosStartMinute,
|
||||||
|
aosEndMinute = aosEndMinute,
|
||||||
|
invertAosTimeWindow = invertAosTimeWindow,
|
||||||
|
showDeepSpace = showDeepSpace
|
||||||
|
)
|
||||||
}
|
}
|
||||||
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
val modes = settingsRepo.selectedSatModes.value
|
||||||
|
satelliteRepo.calculatePasses(
|
||||||
|
time = System.currentTimeMillis(),
|
||||||
|
hoursAhead = hoursAhead,
|
||||||
|
minElevation = minElevation,
|
||||||
|
aosStartMinute = aosStartMinute,
|
||||||
|
aosEndMinute = aosEndMinute,
|
||||||
|
invertAosTimeWindow = invertAosTimeWindow,
|
||||||
|
modes = modes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
|
||||||
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
|
_uiState.update { it.copy(modes = modes) }
|
||||||
|
satelliteRepo.calculatePasses(
|
||||||
|
time = System.currentTimeMillis(),
|
||||||
|
hoursAhead = _uiState.value.hours,
|
||||||
|
minElevation = _uiState.value.elevation,
|
||||||
|
aosStartMinute = _uiState.value.aosStartMinute,
|
||||||
|
aosEndMinute = _uiState.value.aosEndMinute,
|
||||||
|
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
||||||
|
modes = modes
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshPasses() = viewModelScope.launch {
|
private fun refreshPasses() = viewModelScope.launch {
|
||||||
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
satelliteRepo.calculatePasses(
|
||||||
|
time = System.currentTimeMillis(),
|
||||||
|
hoursAhead = settings.hoursAhead,
|
||||||
|
minElevation = settings.minElevation,
|
||||||
|
aosStartMinute = settings.aosStartMinute,
|
||||||
|
aosEndMinute = settings.aosEndMinute,
|
||||||
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
|
modes = settingsRepo.selectedSatModes.value
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -59,6 +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.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
|
||||||
@@ -66,6 +68,7 @@ import com.rtbishop.look4sat.core.presentation.NextPassRow
|
|||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
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
|
||||||
@@ -73,6 +76,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
|
Calculator("Calculator"),
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,16 +153,31 @@ private fun PagerCard(
|
|||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val pages = RadarPage.entries
|
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||||
|
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val pages = remember(hasCalculatorPage) {
|
||||||
|
buildList {
|
||||||
|
add(RadarPage.Transceivers)
|
||||||
|
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||||
|
add(RadarPage.Sstv)
|
||||||
|
}
|
||||||
|
}
|
||||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
val 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()) {
|
||||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||||
|
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = selectedTabIndex == 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) }
|
||||||
)
|
)
|
||||||
@@ -170,13 +189,20 @@ private fun PagerCard(
|
|||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
when (pages[pageIndex]) {
|
when (pages[pageIndex]) {
|
||||||
RadarPage.Transceivers -> TransceiversPage(
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
transceivers = uiState.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
selectedUuid = uiState.selectedTransmitterUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
radioControl = uiState.radioControl,
|
radioControl = uiState.radioControl,
|
||||||
onAction = onAction
|
onAction = onAction
|
||||||
)
|
)
|
||||||
|
RadarPage.Calculator -> CalculatorPage(
|
||||||
|
transceivers = uiState.transceivers.transmitters,
|
||||||
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
|
orbitalPos = uiState.orbitalPos,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
@@ -189,25 +215,26 @@ private fun PagerCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||||
val satellitePos = uiState.orbitalPos
|
val satellitePos = uiState.orbitalPos
|
||||||
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
|
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
// Always call these composables unconditionally — conditional composable calls violate
|
||||||
val borderAlpha by infiniteTransition.animateFloat(
|
// Compose's slot-table stability rules and can crash or produce incorrect state
|
||||||
initialValue = 1.0f,
|
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
||||||
targetValue = 0.0f,
|
val borderAlpha by infiniteTransition.animateFloat(
|
||||||
animationSpec = infiniteRepeatable(
|
initialValue = 1.0f,
|
||||||
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
targetValue = 0.0f,
|
||||||
repeatMode = RepeatMode.Reverse
|
animationSpec = infiniteRepeatable(
|
||||||
),
|
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
||||||
label = "eclipsedBorderAlpha"
|
repeatMode = RepeatMode.Reverse
|
||||||
)
|
),
|
||||||
|
label = "eclipsedBorderAlpha"
|
||||||
|
)
|
||||||
|
val borderModifier = if (shouldAnimateBorder) {
|
||||||
Modifier.border(
|
Modifier.border(
|
||||||
width = 0.5.dp,
|
width = 0.5.dp,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
||||||
shape = MaterialTheme.shapes.medium
|
shape = MaterialTheme.shapes.medium
|
||||||
)
|
)
|
||||||
} else {
|
} else Modifier
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
val position = uiState.orbitalPos
|
val position = uiState.orbitalPos
|
||||||
@@ -222,6 +249,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
|||||||
azimElev = uiState.orientationValues,
|
azimElev = uiState.orientationValues,
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
shouldShowSweep = uiState.shouldShowSweep,
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
shouldUseCompass = uiState.shouldUseCompass,
|
||||||
|
shouldFlipRadar = uiState.shouldFlipRadar,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
sunPosition = uiState.sunPosition,
|
sunPosition = uiState.sunPosition,
|
||||||
moonPosition = uiState.moonPosition,
|
moonPosition = uiState.moonPosition,
|
||||||
@@ -294,4 +322,3 @@ private fun RadarLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
||||||
|
import com.rtbishop.look4sat.core.domain.sstv.SstvQualityMetrics
|
||||||
|
|
||||||
data class RadioPanelState(
|
data class RadioPanelState(
|
||||||
val label: String = "",
|
val label: String = "",
|
||||||
@@ -34,7 +35,6 @@ data class RadioPanelState(
|
|||||||
data class RadioControlSubState(
|
data class RadioControlSubState(
|
||||||
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
||||||
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
||||||
val transponders: List<SatRadio> = emptyList(),
|
|
||||||
val selectedTransponderUuid: String? = null,
|
val selectedTransponderUuid: String? = null,
|
||||||
val txBaseFrequencyHz: Long? = null,
|
val txBaseFrequencyHz: Long? = null,
|
||||||
val ctcssTone: Double? = null,
|
val ctcssTone: Double? = null,
|
||||||
@@ -42,34 +42,40 @@ data class RadioControlSubState(
|
|||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class TransceiverSubState(
|
||||||
|
val transmitters: List<SatRadio> = emptyList(),
|
||||||
|
val selectedUuid: String? = null,
|
||||||
|
val selectedFrequency: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class RadarState(
|
data class RadarState(
|
||||||
val currentPass: OrbitalPass? = null,
|
val currentPass: OrbitalPass? = null,
|
||||||
val currentTime: String = "00:00:00",
|
val currentTime: String = "00:00:00",
|
||||||
val isTimeAos: Boolean = true,
|
val isTimeAos: Boolean = true,
|
||||||
val isLos: Boolean = false,
|
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
||||||
val orbitalPos: OrbitalPos? = null,
|
val orbitalPos: OrbitalPos? = null,
|
||||||
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 transmitters: List<SatRadio> = emptyList(),
|
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||||
val selectedTransmitterUuid: String? = null,
|
|
||||||
val selectedFrequency: Long? = null,
|
|
||||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||||
val sstv: SstvSubState = SstvSubState()
|
val sstv: SstvSubState = SstvSubState()
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class SstvStatus { Idle, Recording, Saving }
|
enum class SstvStatus { Idle, Recording }
|
||||||
|
|
||||||
data class SstvSubState(
|
data class SstvSubState(
|
||||||
val status: SstvStatus = SstvStatus.Idle,
|
val status: SstvStatus = SstvStatus.Idle,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
val hasPermission: Boolean = false,
|
val hasPermission: Boolean = false,
|
||||||
val selectedMode: String = "Auto",
|
val selectedMode: String = "Auto",
|
||||||
val supportedModes: List<String> = emptyList(),
|
val supportedModes: List<String> = emptyList(),
|
||||||
val currentFrame: SstvFrame? = null
|
val currentFrame: SstvFrame? = null,
|
||||||
|
val diagnosticsMetrics: SstvQualityMetrics? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface RadarAction {
|
sealed interface RadarAction {
|
||||||
@@ -77,7 +83,6 @@ sealed interface RadarAction {
|
|||||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||||
|
|
||||||
// Radio control actions
|
// Radio control actions
|
||||||
data class SelectTransponder(val uuid: String) : RadarAction
|
|
||||||
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
||||||
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
||||||
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
@@ -64,7 +65,7 @@ import kotlin.math.sin
|
|||||||
|
|
||||||
private const val CIRCLES = 3
|
private const val CIRCLES = 3
|
||||||
private const val STROKE_WIDTH = 6f
|
private const val STROKE_WIDTH = 6f
|
||||||
private const val SWEEP_INCREMENT = 360f / 12f / 60f
|
private const val SWEEP_DURATION_MS = 8_000
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarViewCompose(
|
fun RadarViewCompose(
|
||||||
@@ -73,6 +74,7 @@ fun RadarViewCompose(
|
|||||||
azimElev: Pair<Float, Float>,
|
azimElev: Pair<Float, Float>,
|
||||||
shouldShowSweep: Boolean,
|
shouldShowSweep: Boolean,
|
||||||
shouldUseCompass: Boolean,
|
shouldUseCompass: Boolean,
|
||||||
|
shouldFlipRadar: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sunPosition: CelestialComputer.SunPosition? = null,
|
sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
moonPosition: CelestialComputer.MoonPosition? = null,
|
moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
@@ -88,23 +90,46 @@ 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
|
||||||
|
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||||
|
val sweepDegrees by sweepTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||||
|
label = "sweepDegrees"
|
||||||
|
)
|
||||||
val measurer = rememberTextMeasurer()
|
val 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)
|
||||||
var sweepDegrees by remember { mutableFloatStateOf(0f) }
|
// Track path cache — keyed by both canvas size and items reference so it rebuilds
|
||||||
|
// when the satellite track data arrives asynchronously after the first composition
|
||||||
var cachedRadius by remember { mutableFloatStateOf(0f) }
|
var cachedRadius by remember { mutableFloatStateOf(0f) }
|
||||||
|
var cachedItemsRef by remember { mutableStateOf<List<OrbitalPos>>(emptyList()) }
|
||||||
|
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)) }
|
||||||
|
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
||||||
|
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
||||||
|
|
||||||
Canvas(modifier = modifier.aspectRatio(1f)) {
|
Canvas(modifier = modifier.aspectRatio(1f)) {
|
||||||
val radius = size.minDimension / 2f * 0.95f
|
val radius = size.minDimension / 2f * 0.95f
|
||||||
if (radius != cachedRadius) {
|
// Rebuild track path and sweep brush when canvas size or track data changes
|
||||||
|
if (radius != cachedRadius || items !== cachedItemsRef) {
|
||||||
trackPath = createTrackPath(items, radius)
|
trackPath = createTrackPath(items, radius)
|
||||||
trackEffect = createTrackEffect(trackPath)
|
trackEffect = createTrackEffect(trackPath)
|
||||||
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedRadius = radius
|
cachedRadius = radius
|
||||||
|
cachedItemsRef = items
|
||||||
|
cachedSweepColor = primaryColor
|
||||||
|
} else if (primaryColor != cachedSweepColor) {
|
||||||
|
// Rebuild brush on theme change without waiting for a size change
|
||||||
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
|
cachedSweepColor = primaryColor
|
||||||
}
|
}
|
||||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
|
||||||
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor)
|
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
|
||||||
|
rotate(rotation) {
|
||||||
|
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) {
|
||||||
@@ -127,7 +152,6 @@ fun RadarViewCompose(
|
|||||||
}
|
}
|
||||||
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
|
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
|
||||||
}
|
}
|
||||||
sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,10 +196,13 @@ private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Co
|
|||||||
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
|
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
|
private fun makeSweepBrush(center: Offset, color: Color): ShaderBrush {
|
||||||
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
||||||
val colorStops = listOf(0.64f, 0.995f, 1f)
|
val colorStops = listOf(0.64f, 0.995f, 1f)
|
||||||
val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
return ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, brush: ShaderBrush) {
|
||||||
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+162
-79
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.repository.IReporter
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy
|
||||||
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
@@ -40,6 +41,7 @@ import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -48,7 +50,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
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
|
||||||
import java.util.Locale
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class RadarViewModel(
|
class RadarViewModel(
|
||||||
private val bluetoothReporter: IReporter,
|
private val bluetoothReporter: IReporter,
|
||||||
@@ -65,14 +67,22 @@ 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
|
||||||
|
|
||||||
|
// Celestial positions change slowly, recompute at most once per minute
|
||||||
|
private var lastCelestialUpdateMs = 0L
|
||||||
|
private var cachedSunPos: CelestialComputer.SunPosition? = null
|
||||||
|
private var cachedMoonPos: CelestialComputer.MoonPosition? = null
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
RadarState(
|
RadarState(
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
orientationValues = sensorsRepo.orientation.value,
|
orientationValues = sensorsRepo.sensorData.value,
|
||||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||||
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||||
@@ -81,72 +91,111 @@ class RadarViewModel(
|
|||||||
val uiState: StateFlow<RadarState> = _uiState
|
val uiState: StateFlow<RadarState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectCompassSensor()
|
collectSettingsChanges() // also handles initial sensor subscription
|
||||||
collectSettingsChanges()
|
|
||||||
collectPassAndStartTickLoop()
|
collectPassAndStartTickLoop()
|
||||||
collectRadioTrackingState()
|
collectRadioTrackingState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectCompassSensor() {
|
// Starts sensor collection if not already running.
|
||||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
private fun startSensorCollection() {
|
||||||
viewModelScope.launch {
|
if (sensorCollectionJob?.isActive == true) return
|
||||||
|
sensorCollectionJob = viewModelScope.launch {
|
||||||
sensorsRepo.enableSensor()
|
sensorsRepo.enableSensor()
|
||||||
sensorsRepo.orientation.collect { data ->
|
sensorsRepo.sensorData.collect { data ->
|
||||||
val orientationValues = (data.first + magDeclination) to data.second
|
val orientationValues =
|
||||||
|
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
_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.collectLatest { settings ->
|
settingsRepo.otherSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
compassOffset = settings.radarCompassOffset
|
||||||
|
compassOffsetElev = settings.radarCompassOffsetElev
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUtc = settings.stateOfUtc,
|
||||||
|
shouldShowSweep = settings.stateOfSweep,
|
||||||
|
shouldUseCompass = settings.stateOfSensors,
|
||||||
|
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Reactively wire sensor hardware to the compass setting
|
||||||
|
when {
|
||||||
|
settings.stateOfSensors -> startSensorCollection()
|
||||||
|
else -> stopSensorCollection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pass loading split into focused functions ---
|
||||||
|
|
||||||
private fun collectPassAndStartTickLoop() {
|
private fun collectPassAndStartTickLoop() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val passes = satelliteRepo.passes.value
|
val pass = findCurrentPass() ?: return@launch
|
||||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
val allRadios = loadPassData(pass)
|
||||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
|
|
||||||
_uiState.update { it.copy(currentPass = pass) }
|
|
||||||
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
|
|
||||||
transponders = transmittersList.filter { it.downlinkLow != null }
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
|
|
||||||
}
|
|
||||||
if (transmittersList.isNotEmpty()) {
|
|
||||||
val firstUuid = transmittersList.first().uuid
|
|
||||||
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
|
|
||||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
|
||||||
}
|
|
||||||
if (!pass.isDeepSpace) {
|
|
||||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
|
||||||
_uiState.update { it.copy(satTrack = track) }
|
|
||||||
}
|
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
tickPass(pass, transmittersList)
|
tickPass(pass, allRadios)
|
||||||
delay(1000)
|
delay(1000.milliseconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) {
|
private fun findCurrentPass(): OrbitalPass? {
|
||||||
|
val passes = satelliteRepo.passes.value
|
||||||
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
|
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||||
|
?: passes.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads transmitters and satellite track for pass, sets initial state, returns full radio list
|
||||||
|
private suspend fun loadPassData(pass: OrbitalPass): List<SatRadio> {
|
||||||
|
_uiState.update { it.copy(currentPass = pass) }
|
||||||
|
val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||||
|
transponders = allRadios.filter { it.downlinkLow != null }
|
||||||
|
if (allRadios.isNotEmpty()) {
|
||||||
|
val firstUuid = allRadios.first().uuid
|
||||||
|
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
|
||||||
|
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||||
|
}
|
||||||
|
if (!pass.isDeepSpace) {
|
||||||
|
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||||
|
_uiState.update { it.copy(satTrack = track) }
|
||||||
|
}
|
||||||
|
return allRadios
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-second tick ---
|
||||||
|
|
||||||
|
private suspend fun tickPass(pass: OrbitalPass, allRadios: List<SatRadio>) {
|
||||||
val timeNow = System.currentTimeMillis()
|
val timeNow = System.currentTimeMillis()
|
||||||
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||||
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
|
||||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
// Recompute celestial positions at most once per minute (they move very slowly)
|
||||||
|
if (timeNow - lastCelestialUpdateMs >= 60_000L) {
|
||||||
|
cachedSunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||||
|
cachedMoonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||||
|
lastCelestialUpdateMs = timeNow
|
||||||
|
}
|
||||||
|
|
||||||
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||||
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
currentTime = time, isTimeAos = isAos, isLos = isLos,
|
currentTime = time, isTimeAos = isAos,
|
||||||
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos
|
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
processRadios(transmittersList, pass.orbitalObject, timeNow)
|
processRadios(allRadios, pass.orbitalObject, timeNow)
|
||||||
sendPassData(pos)
|
sendPassData(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,32 +232,22 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
sensorsRepo.disableSensor()
|
stopSensorCollection()
|
||||||
super.onCleared()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
||||||
is RadarAction.SelectTransmitter -> {
|
is RadarAction.SelectTransmitter -> {
|
||||||
val previousUuid = _uiState.value.selectedTransmitterUuid
|
// Compute toggle state before the update so we don't read post-update value
|
||||||
_uiState.update {
|
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||||
val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid
|
val newUuid = if (isTogglingOff) null else action.uuid
|
||||||
it.copy(selectedTransmitterUuid = newUuid)
|
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||||
|
// Only update the tracking service when selecting a different transponder to
|
||||||
|
// avoid resetting a user-adjusted TX base on re-expand
|
||||||
|
if (!isTogglingOff) {
|
||||||
|
transponders.find { it.uuid == action.uuid }?.let { trackingService.setTransponder(it) }
|
||||||
}
|
}
|
||||||
// Also set this as the active transponder for radio tracking.
|
|
||||||
// Always use the original (un-Dopplered) transponders list so that the
|
|
||||||
// TX base frequency is computed from the clean nominal values.
|
|
||||||
// Only update the service when selecting a *different* transponder to
|
|
||||||
// avoid resetting a user-adjusted TX base on re-expand.
|
|
||||||
val selected = transponders.find { it.uuid == action.uuid }
|
|
||||||
if (selected != null && _uiState.value.selectedTransmitterUuid != null && previousUuid != action.uuid) {
|
|
||||||
trackingService.setTransponder(selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadarAction.SelectTransponder -> {
|
|
||||||
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
|
||||||
trackingService.setTransponder(transponder)
|
|
||||||
}
|
}
|
||||||
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||||
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||||
@@ -236,10 +275,10 @@ class RadarViewModel(
|
|||||||
RadarAction.SstvSaveImage -> {
|
RadarAction.SstvSaveImage -> {
|
||||||
val frame = _uiState.value.sstv.currentFrame ?: return
|
val frame = _uiState.value.sstv.currentFrame ?: return
|
||||||
val pixels = frame.imagePixels ?: return
|
val pixels = frame.imagePixels ?: return
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Saving)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = true)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = false)) }
|
||||||
showToast("Image saved")
|
showToast("Image saved")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +288,7 @@ class RadarViewModel(
|
|||||||
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
||||||
}
|
}
|
||||||
RadarAction.SstvReset -> {
|
RadarAction.SstvReset -> {
|
||||||
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
|
sstvDecoder?.clearPixels()
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,13 +324,14 @@ class RadarViewModel(
|
|||||||
frequencyEnabled: Boolean,
|
frequencyEnabled: Boolean,
|
||||||
frequencyFormat: String
|
frequencyFormat: String
|
||||||
) {
|
) {
|
||||||
if (rotatorEnabled) {
|
// Only send rotator commands when the satellite is above the horizon
|
||||||
|
if (rotatorEnabled && orbitalPos.aboveHorizon) {
|
||||||
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
||||||
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
||||||
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
||||||
}
|
}
|
||||||
if (frequencyEnabled) {
|
if (frequencyEnabled) {
|
||||||
_uiState.value.selectedFrequency?.let { freq ->
|
_uiState.value.transceivers.selectedFrequency?.let { freq ->
|
||||||
reporter.reportFrequency(frequencyFormat, freq)
|
reporter.reportFrequency(frequencyFormat, freq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,12 +339,9 @@ class RadarViewModel(
|
|||||||
|
|
||||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
||||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
||||||
val isFreqEnabled =
|
|
||||||
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
// Derive the frequency to report from the user's current selection (if any)
|
val freq = if (state.transceivers.selectedUuid != null) {
|
||||||
val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) {
|
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid }
|
|
||||||
selectedRadio?.let { radio ->
|
selectedRadio?.let { radio ->
|
||||||
val low = radio.downlinkLow
|
val low = radio.downlinkLow
|
||||||
val high = radio.downlinkHigh
|
val high = radio.downlinkHigh
|
||||||
@@ -316,21 +353,42 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
if (state.transmitters == transmitters && state.selectedFrequency == freq) {
|
val current = state.transceivers
|
||||||
|
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(transmitters = transmitters, selectedFrequency = freq)
|
state.copy(transceivers = current.copy(transmitters = transmitters, selectedFrequency = freq))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initSstvDecoder() {
|
private fun initSstvDecoder() {
|
||||||
if (sstvDecoder == null) {
|
if (sstvDecoder == null) {
|
||||||
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
|
val decoder = SstvDecoder(
|
||||||
|
sampleRate = audioCapture.sampleRate,
|
||||||
|
targetRmsLevel = SSTV_TARGET_RMS,
|
||||||
|
includeScopeData = SSTV_INCLUDE_SCOPE,
|
||||||
|
enableRmsNormalization = SSTV_ENABLE_RMS_NORMALIZATION,
|
||||||
|
preFilterCutoffHz = SSTV_PREFILTER_CUTOFF_HZ,
|
||||||
|
enablePreFilter = SSTV_ENABLE_PREFILTER,
|
||||||
|
enableDiagnosticsHandle = SSTV_ENABLE_DIAGNOSTICS_HANDLE,
|
||||||
|
lineRecoveryStrategy = SSTV_LINE_RECOVERY_STRATEGY
|
||||||
|
)
|
||||||
sstvDecoder = decoder
|
sstvDecoder = decoder
|
||||||
|
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
decoder.frames.collect { frame ->
|
decoder.frames.collect { frame ->
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
|
val metrics = decoder.getQualityMetrics()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(sstv = it.sstv.copy(currentFrame = frame, diagnosticsMetrics = metrics))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decoder.getDiagnosticsHandle()?.let { handle ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
handle.metrics.collectLatest { metrics ->
|
||||||
|
_uiState.update { it.copy(sstv = it.sstv.copy(diagnosticsMetrics = metrics)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,6 +412,39 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
// SSTV Decoder Tuning Parameters
|
||||||
|
// ==============================
|
||||||
|
// SSTV_TARGET_RMS: Normalized signal level before FM demodulation.
|
||||||
|
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
||||||
|
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
||||||
|
// - 0.50-0.60: Conservative, best for noisy environments
|
||||||
|
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
|
||||||
|
|
||||||
|
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
||||||
|
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
||||||
|
|
||||||
|
// High-pass pre-filter to remove DC offset and subsonic noise before RMS normalization.
|
||||||
|
// Improves weak signal robustness by cleaning the noise floor estimate.
|
||||||
|
private const val SSTV_PREFILTER_CUTOFF_HZ = 500.0 // Removes everything below 500 Hz
|
||||||
|
|
||||||
|
// Enable pre-filtering. Set false to skip (minimal CPU cost if disabled).
|
||||||
|
private const val SSTV_ENABLE_PREFILTER = true
|
||||||
|
|
||||||
|
// Diagnostics handle can be enabled temporarily to capture field reports.
|
||||||
|
// Interpretation notes for noisy recordings:
|
||||||
|
// - syncHitRate < 0.30: weak/noisy signal path or mistuned gain
|
||||||
|
// - predictedLineBursts rising quickly: frequent sync loss and likely vertical collapse
|
||||||
|
// - maxPredictedStreak > 3 in Robot36 mode: noisy path and synthetic line flooding
|
||||||
|
private const val SSTV_ENABLE_DIAGNOSTICS_HANDLE = false
|
||||||
|
|
||||||
|
// Line recovery policy:
|
||||||
|
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
||||||
|
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
||||||
|
// Ask users to compare both if they report line-skipping/compression artifacts.
|
||||||
|
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
|
||||||
|
|
||||||
|
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
||||||
|
private const val SSTV_INCLUDE_SCOPE = false
|
||||||
|
|
||||||
val CTCSS_TONES = listOf(
|
val CTCSS_TONES = listOf(
|
||||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||||
@@ -362,14 +453,6 @@ class RadarViewModel(
|
|||||||
218.1, 225.7, 233.6, 241.8, 250.3
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
)
|
)
|
||||||
|
|
||||||
fun formatFrequency(frequencyHz: Long): String {
|
|
||||||
if (frequencyHz <= 0) return "---"
|
|
||||||
val mhz = frequencyHz / 1_000_000
|
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
|
||||||
val hz = frequencyHz % 1_000
|
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
|
|||||||
@@ -53,14 +53,17 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
|
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SstvPage(
|
internal fun SstvPage(
|
||||||
sstv: SstvSubState,
|
sstv: SstvSubState,
|
||||||
|
dopplerFrequency: String?,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -92,53 +95,44 @@ internal fun SstvPage(
|
|||||||
|
|
||||||
if (showModeDialog.value) {
|
if (showModeDialog.value) {
|
||||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||||
Dialog(onDismissRequest = { showModeDialog.value = false }) {
|
val dismiss = { showModeDialog.value = false }
|
||||||
ElevatedCard {
|
SharedDialog(
|
||||||
Column(
|
title = "SSTV Mode",
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
onDismissRequest = dismiss,
|
||||||
modifier = Modifier.padding(vertical = 16.dp)
|
onAccept = dismiss
|
||||||
) {
|
) { _ ->
|
||||||
Text(
|
LazyColumn(
|
||||||
text = "SSTV Mode",
|
modifier = Modifier
|
||||||
fontSize = 16.sp,
|
.fillMaxHeight(0.7f)
|
||||||
fontWeight = FontWeight.Medium,
|
.background(MaterialTheme.colorScheme.background),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
) {
|
||||||
)
|
items(allModes) { mode ->
|
||||||
LazyColumn(
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(0.5f)
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
.clickable {
|
||||||
) {
|
onAction(RadarAction.SstvSelectMode(mode))
|
||||||
items(allModes) { mode ->
|
showModeDialog.value = false
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clickable {
|
|
||||||
onAction(RadarAction.SstvSelectMode(mode))
|
|
||||||
showModeDialog.value = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = mode,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(start = 16.dp)
|
|
||||||
)
|
|
||||||
RadioButton(
|
|
||||||
selected = mode == sstv.selectedMode,
|
|
||||||
onClick = null,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
) {
|
||||||
|
Text(
|
||||||
|
text = mode,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
RadioButton(
|
||||||
|
selected = mode == sstv.selectedMode,
|
||||||
|
onClick = null,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,60 +173,95 @@ internal fun SstvPage(
|
|||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val qualityText = frame?.let {
|
||||||
|
val syncPct = (it.syncHitRate * 100f).roundToInt()
|
||||||
|
val gainX10 = (it.appliedGain * 10f).roundToInt() / 10f
|
||||||
|
val inputRmsX1000 = (it.inputRms * 1000f).roundToInt() / 1000f
|
||||||
|
val scope = if (it.scopePixels != null) " | Scope ${it.scopeWidth}x${it.scopeHeight}" else ""
|
||||||
|
val complete = if (it.imageComplete) " | Complete" else ""
|
||||||
|
"RMS $inputRmsX1000 | Gain x$gainX10 | Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}$scope$complete"
|
||||||
|
} ?: sstv.diagnosticsMetrics?.let {
|
||||||
|
val syncPct = (it.syncHitRate * 100f).roundToInt()
|
||||||
|
"Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}"
|
||||||
|
}
|
||||||
|
|
||||||
// Bottom control bar — dark, blends with the black canvas
|
// Bottom control bar — dark, blends with the black canvas
|
||||||
Row(
|
Column(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
// Mode button — opens dialog
|
// Doppler-corrected downlink frequency hint
|
||||||
ElevatedCard(
|
OutlinedText(
|
||||||
modifier = Modifier.weight(1f).height(48.dp),
|
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
|
||||||
onClick = { showModeDialog.value = true },
|
fontSize = 18.sp,
|
||||||
colors = CardDefaults.elevatedCardColors(
|
fontWeight = FontWeight.Bold,
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
fillColor = MaterialTheme.colorScheme.primary,
|
||||||
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
if (qualityText != null) {
|
||||||
|
OutlinedText(
|
||||||
|
text = qualityText,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fillColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
// Mode button — opens dialog
|
||||||
Text(
|
ElevatedCard(
|
||||||
text = "Mode: ${sstv.selectedMode}",
|
modifier = Modifier.weight(1f).height(48.dp),
|
||||||
fontSize = 14.sp,
|
onClick = { showModeDialog.value = true },
|
||||||
maxLines = 1,
|
colors = CardDefaults.elevatedCardColors(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
modifier = Modifier.infiniteMarquee()
|
|
||||||
)
|
)
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = "Mode: ${sstv.selectedMode}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.infiniteMarquee()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// Reset button — clears decoder state and image
|
||||||
// Reset button — clears decoder state and image
|
IconCard(
|
||||||
IconCard(
|
action = { onAction(RadarAction.SstvReset) },
|
||||||
action = { onAction(RadarAction.SstvReset) },
|
resId = R.drawable.ic_delete
|
||||||
resId = R.drawable.ic_delete
|
)
|
||||||
)
|
// Save button — always visible, enabled when there are pixels
|
||||||
// Save button — always visible, enabled when there are pixels
|
IconCard(
|
||||||
IconCard(
|
action = { onAction(RadarAction.SstvSaveImage) },
|
||||||
action = { onAction(RadarAction.SstvSaveImage) },
|
resId = R.drawable.ic_save,
|
||||||
resId = R.drawable.ic_save,
|
enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
|
||||||
enabled = sstv.currentFrame?.imagePixels != null
|
)
|
||||||
)
|
// Record / Stop button
|
||||||
// Record / Stop button
|
val playAction = {
|
||||||
val playAction = {
|
when (sstv.status) {
|
||||||
when (sstv.status) {
|
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
||||||
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
||||||
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
}
|
||||||
SstvStatus.Saving -> {}
|
|
||||||
}
|
}
|
||||||
}
|
val playColors = CardDefaults.elevatedCardColors(
|
||||||
val playColors = CardDefaults.elevatedCardColors(
|
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
|
||||||
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
|
else MaterialTheme.colorScheme.surface
|
||||||
else MaterialTheme.colorScheme.surface
|
)
|
||||||
)
|
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
|
||||||
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
|
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Icon(painter = painterResource(playIcon), contentDescription = null)
|
||||||
Icon(painter = painterResource(playIcon), contentDescription = null)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+188
-10
@@ -39,14 +39,19 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -55,15 +60,20 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TransceiversPage(
|
fun TransceiversPage(
|
||||||
@@ -82,7 +92,7 @@ fun TransceiversPage(
|
|||||||
if (selectedUuid != null) {
|
if (selectedUuid != null) {
|
||||||
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
kotlinx.coroutines.delay(300)
|
kotlinx.coroutines.delay(300.milliseconds)
|
||||||
listState.animateScrollToItem(index)
|
listState.animateScrollToItem(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +112,69 @@ fun TransceiversPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalculatorPage(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
selectedUuid: String?,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val calculatorTransceivers = remember(transceivers) {
|
||||||
|
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
||||||
|
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
||||||
|
?: calculatorTransceivers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTransceiver == null) {
|
||||||
|
EmptyTransceiversContent(modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
if (calculatorTransceivers.size > 1) {
|
||||||
|
item {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
calculatorTransceivers.forEach { radio ->
|
||||||
|
FilterChip(
|
||||||
|
selected = radio.uuid == selectedTransceiver.uuid,
|
||||||
|
onClick = {
|
||||||
|
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = transceiverTitle(radio),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
DopplerCalculatorPanel(
|
||||||
|
transponder = selectedTransceiver,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@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()) {
|
||||||
@@ -170,12 +243,8 @@ 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 = fullTitle,
|
text = transceiverTitle(radio),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -325,7 +394,7 @@ private fun ExpandedRadioControl(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
@@ -335,7 +404,7 @@ private fun ExpandedRadioControl(
|
|||||||
val upHigh = radio.uplinkHigh
|
val upHigh = radio.uplinkHigh
|
||||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||||
Text(
|
Text(
|
||||||
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
|
text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -367,10 +436,11 @@ private fun ExpandedRadioControl(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
maxItemsInEachRow = 5,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
val chipModifier = Modifier.width(64.dp)
|
val chipModifier = Modifier.weight(1f)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioControl.ctcssTone == null,
|
selected = radioControl.ctcssTone == null,
|
||||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
@@ -434,6 +504,108 @@ private fun ExpandedRadioControl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DopplerCalculatorPanel(
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||||
|
|
||||||
|
var txInputMHz by remember(transponder.uuid) { mutableStateOf("") }
|
||||||
|
var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") }
|
||||||
|
var offsetKHz by remember(transponder.uuid) { mutableStateOf("") }
|
||||||
|
var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) }
|
||||||
|
|
||||||
|
fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L
|
||||||
|
fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0)
|
||||||
|
fun mhzToHz(text: String): Long? = text.toDoubleOrNull()
|
||||||
|
?.takeIf { it > 0.0 }
|
||||||
|
?.let { (it * 1_000_000).toLong() }
|
||||||
|
|
||||||
|
fun calculateDownlink(txText: String, offsetText: String): String? {
|
||||||
|
val txHz = mhzToHz(txText) ?: return null
|
||||||
|
return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = txHz,
|
||||||
|
transponder = transponder,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = offsetHz(offsetText)
|
||||||
|
)?.let(::formatHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateUplink(rxText: String, offsetText: String): String? {
|
||||||
|
val rxHz = mhzToHz(rxText) ?: return null
|
||||||
|
return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = rxHz,
|
||||||
|
transponder = transponder,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = offsetHz(offsetText)
|
||||||
|
)?.let(::formatHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(orbitalPos, transponder.uuid) {
|
||||||
|
if (lastEditedBy == EditedField.TX) {
|
||||||
|
calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it }
|
||||||
|
} else {
|
||||||
|
calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = offsetKHz,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
offsetKHz = newValue
|
||||||
|
if (lastEditedBy == EditedField.TX) {
|
||||||
|
calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it }
|
||||||
|
} else {
|
||||||
|
calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = txInputMHz,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
txInputMHz = newValue
|
||||||
|
lastEditedBy = EditedField.TX
|
||||||
|
rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rxInputMHz,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
rxInputMHz = newValue
|
||||||
|
lastEditedBy = EditedField.RX
|
||||||
|
txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class EditedField { TX, RX }
|
||||||
|
|
||||||
@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 {
|
||||||
@@ -449,5 +621,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun transceiverTitle(radio: SatRadio): String {
|
||||||
|
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||||
|
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||||
|
return "$title ($mode)"
|
||||||
|
}
|
||||||
|
|
||||||
private val FREQ_ADJUSTMENTS =
|
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")
|
||||||
+9
-9
@@ -45,18 +45,18 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog
|
|||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MultiTypesDialogPreview() {
|
private fun MultiModesDialogPreview() {
|
||||||
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
|
val modes = listOf("FM", "APT", "SSTV", "BPSK")
|
||||||
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
|
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MultiTypesDialog(
|
internal fun MultiModesDialog(
|
||||||
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val selected = remember { mutableStateOf(types.toSet()) }
|
val selected = remember { mutableStateOf(modes.toSet()) }
|
||||||
val toggle = { type: String ->
|
val toggle = { mode: String ->
|
||||||
selected.value = if (type in selected.value) selected.value - type else selected.value + type
|
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||||
}
|
}
|
||||||
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 MultiTypesDialog(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allTypes) { index, item ->
|
itemsIndexed(allModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+77
-23
@@ -62,10 +62,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|||||||
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
@@ -87,19 +87,28 @@ private fun SatellitesScreen(
|
|||||||
navigateUp: () -> Unit
|
navigateUp: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isDialogShown) {
|
if (uiState.isDialogShown) {
|
||||||
MultiTypesDialog(
|
MultiModesDialog(
|
||||||
allTypes = uiState.typesList,
|
allModes = uiState.modesList,
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
|
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
accept = { onAction(SatellitesAction.SelectTypes(it)) }
|
accept = { onAction(SatellitesAction.SelectModes(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWarning) {
|
if (uiState.shouldSeeWarning) {
|
||||||
InfoDialog(
|
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
||||||
stringResource(R.string.sat_warning_title),
|
SharedDialog(
|
||||||
stringResource(R.string.sat_warning_message)
|
title = stringResource(R.string.sat_warning_title),
|
||||||
) {
|
onDismissRequest = dismiss,
|
||||||
onAction(SatellitesAction.DismissWarning)
|
onAccept = dismiss,
|
||||||
|
titleFontSize = 18
|
||||||
|
) { padding ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sat_warning_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +123,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 {
|
||||||
TypeCard(
|
ModeCard(
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(
|
PrimaryIconCard(
|
||||||
@@ -148,9 +157,9 @@ private fun SatellitesScreen(
|
|||||||
resId = R.drawable.ic_done,
|
resId = R.drawable.ic_done,
|
||||||
modifier = Modifier.semantics { contentDescription = primCardCd }
|
modifier = Modifier.semantics { contentDescription = primCardCd }
|
||||||
)
|
)
|
||||||
TypeCard(
|
ModeCard(
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
SearchBar(
|
SearchBar(
|
||||||
@@ -232,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
|
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -243,12 +252,12 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_tags),
|
painter = painterResource(id = R.drawable.ic_radios),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sat_type_hint, typesText),
|
text = stringResource(R.string.sat_type_hint, modesText),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -314,9 +323,54 @@ 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)) {
|
||||||
items(items = items, key = { item -> item.catnum }) { entry ->
|
stickyHeader(key = "sat_summary_header") {
|
||||||
Satellite(entry, onSelected, Modifier.animateItem())
|
SummaryHeader(
|
||||||
|
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
|
||||||
|
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedItems.isNotEmpty()) {
|
||||||
|
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
|
||||||
|
Satellite(entry, onSelected, Modifier.animateItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableItems.isNotEmpty()) {
|
||||||
|
items(items = availableItems, key = { item -> item.catnum }) { entry ->
|
||||||
|
Satellite(entry, onSelected, Modifier.animateItem())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SummaryHeader(selectedText: String, availableText: String) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selectedText,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = availableText,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+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 currentTypes: List<String> = emptyList(),
|
val currentModes: List<String> = emptyList(),
|
||||||
val typesList: List<String> = emptyList()
|
val modesList: 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 SelectTypes(val types: List<String>) : SatellitesAction
|
data class SelectModes(val modes: List<String>) : SatellitesAction
|
||||||
data object ToggleTypesDialog : SatellitesAction
|
data object ToggleModesDialog : SatellitesAction
|
||||||
data object UnselectAll : SatellitesAction
|
data object UnselectAll : SatellitesAction
|
||||||
}
|
}
|
||||||
+15
-10
@@ -35,13 +35,13 @@ class SatellitesViewModel(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val defaultTypes = selectionRepo.getCurrentTypes()
|
private val defaultModes = selectionRepo.getCurrentModes()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
SatellitesState(
|
SatellitesState(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
||||||
currentTypes = defaultTypes,
|
currentModes = defaultModes,
|
||||||
typesList = selectionRepo.getTypesList()
|
modesList = selectionRepo.getModesList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
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.setTypes(defaultTypes)
|
selectionRepo.setModes(defaultModes)
|
||||||
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,6 +59,11 @@ 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) {
|
||||||
@@ -68,8 +73,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.SelectTypes -> selectTypes(action.types)
|
is SatellitesAction.SelectModes -> selectModes(action.modes)
|
||||||
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
|
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
|
||||||
SatellitesAction.UnselectAll -> selectAll(false)
|
SatellitesAction.UnselectAll -> selectAll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,12 +91,12 @@ class SatellitesViewModel(
|
|||||||
selectionRepo.setSelection(listOf(id), isTicked.not())
|
selectionRepo.setSelection(listOf(id), isTicked.not())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectTypes(types: List<String>) = viewModelScope.launch {
|
private fun selectModes(modes: List<String>) = viewModelScope.launch {
|
||||||
selectionRepo.setTypes(types)
|
selectionRepo.setModes(modes)
|
||||||
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
|
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleTypesDialog() {
|
private fun toggleModesDialog() {
|
||||||
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+209
-81
@@ -17,27 +17,29 @@
|
|||||||
*/
|
*/
|
||||||
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.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
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
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.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
|
||||||
@@ -62,16 +64,28 @@ 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) {
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = latValue.value,
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
onValueChange = { latValue.value = it },
|
modifier = Modifier
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }
|
.fillMaxWidth()
|
||||||
)
|
.padding(horizontal = LocalSpacing.current.large)
|
||||||
OutlinedTextField(
|
) {
|
||||||
value = lonValue.value,
|
OutlinedTextField(
|
||||||
onValueChange = { lonValue.value = it },
|
value = latValue.value,
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }
|
onValueChange = { latValue.value = it },
|
||||||
)
|
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lonValue.value,
|
||||||
|
onValueChange = { lonValue.value = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +107,20 @@ 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(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
|
SharedDialog(
|
||||||
|
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
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = LocalSpacing.current.large),
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +133,7 @@ private fun TransceiversDialogPreview() {
|
|||||||
useCustomTransceivers = true,
|
useCustomTransceivers = true,
|
||||||
tleUrl = "https://example.com/tle.txt",
|
tleUrl = "https://example.com/tle.txt",
|
||||||
transceiversUrl = "https://example.com/tx.json",
|
transceiversUrl = "https://example.com/tx.json",
|
||||||
|
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
|
||||||
onImportTle = {},
|
onImportTle = {},
|
||||||
onImportTransceivers = {},
|
onImportTransceivers = {},
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
@@ -125,6 +148,7 @@ fun DataSourcesDialog(
|
|||||||
useCustomTransceivers: Boolean,
|
useCustomTransceivers: Boolean,
|
||||||
tleUrl: String,
|
tleUrl: String,
|
||||||
transceiversUrl: String,
|
transceiversUrl: String,
|
||||||
|
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
|
||||||
onImportTle: () -> Unit,
|
onImportTle: () -> Unit,
|
||||||
onImportTransceivers: () -> Unit,
|
onImportTransceivers: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -136,7 +160,12 @@ 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(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
|
onSave(
|
||||||
|
isEnabledCustomTle.value,
|
||||||
|
isEnabledCustomTransceivers.value,
|
||||||
|
urlTle.value,
|
||||||
|
urlTransceivers.value
|
||||||
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
val onCancel = { onDismiss() }
|
val onCancel = { onDismiss() }
|
||||||
@@ -152,7 +181,7 @@ fun DataSourcesDialog(
|
|||||||
onImportTle()
|
onImportTle()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
text = "TLE (3LE)\nR4UAB (.txt)",
|
text = "TLE/3LE (.txt)\nOMM (.csv)",
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
CardButton(
|
CardButton(
|
||||||
@@ -173,7 +202,16 @@ fun DataSourcesDialog(
|
|||||||
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
|
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
|
||||||
Switch(
|
Switch(
|
||||||
checked = isEnabledCustomTle.value,
|
checked = isEnabledCustomTle.value,
|
||||||
onCheckedChange = { isEnabledCustomTle.value = it }
|
onCheckedChange = { enabled ->
|
||||||
|
if (!enabled) {
|
||||||
|
isEnabledCustomTle.value = false
|
||||||
|
} else {
|
||||||
|
requestCustomSourcesPermission(
|
||||||
|
{ isEnabledCustomTle.value = true },
|
||||||
|
{ isEnabledCustomTle.value = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -193,7 +231,16 @@ fun DataSourcesDialog(
|
|||||||
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
|
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
|
||||||
Switch(
|
Switch(
|
||||||
checked = isEnabledCustomTransceivers.value,
|
checked = isEnabledCustomTransceivers.value,
|
||||||
onCheckedChange = { isEnabledCustomTransceivers.value = it }
|
onCheckedChange = { enabled ->
|
||||||
|
if (!enabled) {
|
||||||
|
isEnabledCustomTransceivers.value = false
|
||||||
|
} else {
|
||||||
|
requestCustomSourcesPermission(
|
||||||
|
{ isEnabledCustomTransceivers.value = true },
|
||||||
|
{ isEnabledCustomTransceivers.value = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -204,6 +251,7 @@ fun DataSourcesDialog(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = isEnabledCustomTransceivers.value,
|
enabled = isEnabledCustomTransceivers.value,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +342,7 @@ fun NetworkOutputDialog(
|
|||||||
onFormatChange = { frequencyFormat.value = it },
|
onFormatChange = { frequencyFormat.value = it },
|
||||||
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,11 +395,15 @@ 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 = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
val rotatorAddress =
|
||||||
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
||||||
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
||||||
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
val frequencyState =
|
||||||
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
||||||
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
val frequencyAddress =
|
||||||
|
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,
|
||||||
@@ -387,6 +440,7 @@ fun BluetoothOutputDialog(
|
|||||||
onFormatChange = { frequencyFormat.value = it },
|
onFormatChange = { frequencyFormat.value = it },
|
||||||
formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
|
formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,14 +495,14 @@ private fun OutputChannelSection(
|
|||||||
@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 context = androidx.compose.ui.platform.LocalContext.current
|
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val baudRates = listOf(4800, 9600, 38400)
|
|
||||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
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 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) }
|
||||||
@@ -456,14 +510,17 @@ fun RadioControlDialog(
|
|||||||
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 pairedDevices = remember {
|
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
try {
|
val isSingleRadio = isIcom && splitMode.value
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
||||||
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
|
// Reset split mode when switching away from IC-705
|
||||||
} catch (_: SecurityException) {
|
if (!isIcom && splitMode.value) splitMode.value = false
|
||||||
emptyList()
|
|
||||||
}
|
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
||||||
}
|
else RadioControlSettings.BAUD_RATES_YAESU
|
||||||
|
|
||||||
|
// If current baud rate is not in the new list, default to the first available
|
||||||
|
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
||||||
|
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
@@ -471,20 +528,23 @@ fun RadioControlDialog(
|
|||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
radioModel = radioModel.value,
|
radioModel = radioModel.value,
|
||||||
txRadioAddress = txAddress.value,
|
txRadioAddress = txAddress.value,
|
||||||
rxRadioAddress = rxAddress.value,
|
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
||||||
txRadioName = txName.value,
|
txRadioName = txName.value,
|
||||||
rxRadioName = rxName.value,
|
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
||||||
baudRate = baudRate.intValue
|
baudRate = baudRate.intValue,
|
||||||
|
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)) {
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -495,59 +555,98 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Radio model selection
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.rc_radio_model),
|
text = stringResource(R.string.rc_radio_model),
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||||
androidx.compose.material3.FilterChip(
|
FilterChip(
|
||||||
selected = radioModel.value == model,
|
selected = radioModel.value == model,
|
||||||
onClick = { radioModel.value = model },
|
onClick = { radioModel.value = model },
|
||||||
label = { Text(model, fontSize = 12.sp) },
|
label = {
|
||||||
enabled = enabled.value
|
Text(
|
||||||
|
text = compactRadioModelLabel(model),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = enabled.value,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// TX Radio selection
|
val isSplitModeAvailable = enabled.value && isIcom
|
||||||
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
val splitModeLabelColor = if (isSplitModeAvailable) {
|
||||||
if (txAddress.value.isNotBlank()) {
|
MaterialTheme.colorScheme.onSurface
|
||||||
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
|
||||||
}
|
}
|
||||||
CardButton(
|
Row(
|
||||||
onClick = { selectingFor.value = "tx" },
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
text = "Select TX Device",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// RX Radio selection
|
Text("Radio devices", fontWeight = FontWeight.Medium)
|
||||||
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
Row(
|
||||||
if (rxAddress.value.isNotBlank()) {
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
|
|
||||||
}
|
|
||||||
CardButton(
|
|
||||||
onClick = { selectingFor.value = "rx" },
|
|
||||||
text = "Select RX Device",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
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()) {
|
||||||
|
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
if (isSingleRadio && txAddress.value.isNotBlank()) {
|
||||||
|
Text("RX: same as TX", fontSize = 13.sp)
|
||||||
|
} else if (rxAddress.value.isNotBlank()) {
|
||||||
|
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Paired devices list (shown when selecting)
|
|
||||||
if (selectingFor.value.isNotBlank()) {
|
if (selectingFor.value.isNotBlank()) {
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = "Paired Bluetooth Devices:",
|
text = "Paired Bluetooth Devices:",
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
if (pairedDevices.isEmpty()) {
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.")
|
if (pairedBluetoothDevices.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
pairedDevices.forEach { (name, address) ->
|
pairedBluetoothDevices.forEach { (name, address) ->
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -555,6 +654,10 @@ fun RadioControlDialog(
|
|||||||
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
|
||||||
@@ -573,25 +676,50 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
||||||
Row(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
maxItemsInEachRow = 6,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text("Baud Rate:")
|
baudRates.forEach { rate ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
FilterChip(
|
||||||
baudRates.forEach { rate ->
|
selected = rate == baudRate.intValue,
|
||||||
CardButton(
|
onClick = { baudRate.intValue = rate },
|
||||||
onClick = { baudRate.intValue = rate },
|
label = {
|
||||||
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
|
Text(
|
||||||
modifier = Modifier
|
text = baudLabel(rate),
|
||||||
)
|
fontSize = 12.sp,
|
||||||
}
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = enabled.value,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
+164
-32
@@ -25,10 +25,12 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
@@ -38,6 +40,7 @@ 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
|
||||||
@@ -89,9 +92,22 @@ fun SettingsDestination() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||||
val dialogs = rememberDialogVisibility()
|
val dialogs = rememberDialogVisibility()
|
||||||
|
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val permissions = rememberSettingsPermissions(
|
val permissions = rememberSettingsPermissions(
|
||||||
sendAction = onAction,
|
sendAction = onAction,
|
||||||
onBluetoothGranted = { dialogs.bluetooth = true }
|
onBluetoothGranted = { dialogs.bluetooth = true },
|
||||||
|
onNetworkGranted = { dialogs.network = true },
|
||||||
|
onCustomSourcesPermissionGranted = {
|
||||||
|
pendingCustomSourcesGrant.value?.invoke()
|
||||||
|
pendingCustomSourcesGrant.value = null
|
||||||
|
pendingCustomSourcesDeny.value = null
|
||||||
|
},
|
||||||
|
onCustomSourcesPermissionDenied = {
|
||||||
|
pendingCustomSourcesDeny.value?.invoke()
|
||||||
|
pendingCustomSourcesGrant.value = null
|
||||||
|
pendingCustomSourcesDeny.value = null
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
@@ -116,6 +132,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
||||||
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
||||||
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
||||||
|
requestCustomSourcesPermission = { onGranted, onDenied ->
|
||||||
|
pendingCustomSourcesGrant.value = onGranted
|
||||||
|
pendingCustomSourcesDeny.value = onDenied
|
||||||
|
permissions.launchCustomSourcesPermission()
|
||||||
|
},
|
||||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||||
onDismiss = { dialogs.dataSources = false },
|
onDismiss = { dialogs.dataSources = false },
|
||||||
@@ -128,7 +149,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
||||||
)
|
)
|
||||||
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
|
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
|
||||||
|
onAction(SettingsAction.UpdateFromWeb)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -170,6 +193,7 @@ 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)) }
|
||||||
)
|
)
|
||||||
@@ -198,7 +222,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_pound)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
||||||
}
|
}
|
||||||
TopBar {
|
TopBar {
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -220,7 +244,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { safeOpenUri(appUrl) },
|
onClick = { safeOpenUri(appUrl) },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
@@ -273,13 +297,27 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
OutputCard(
|
OutputCard(
|
||||||
onNetworkClick = { dialogs.network = true },
|
onNetworkClick = permissions.launchNetwork,
|
||||||
onBluetoothClick = permissions.launchBluetooth,
|
onBluetoothClick = permissions.launchBluetooth,
|
||||||
onRadioControlClick = { dialogs.radioControl = true }
|
onRadioControlClick = { dialogs.radioControl = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
item { CardCredits() }
|
if (isVerticalLayout) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OtherCard(uiState.otherSettings, onAction)
|
||||||
|
CardCredits()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
|
||||||
|
) {
|
||||||
|
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,7 +394,7 @@ private fun LocationCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DataCardPreview() = MainTheme {
|
private fun DataCardPreview() = MainTheme {
|
||||||
val settings = DataSettings(true, 5000, 2500, 0L)
|
val settings = DataSettings(true, 5000, 2500, 0L)
|
||||||
DataCard(settings = settings, {}, {}, {})
|
DataCard(settings = settings, updateFromWeb = {}, clearAllData = {}, showDataSourcesDialog = {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -458,40 +496,83 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
shouldSeeWarning = false,
|
shouldSeeWarning = false,
|
||||||
shouldSeeWhatsNew = false
|
shouldSeeWhatsNew = false
|
||||||
)
|
)
|
||||||
OtherCard(settings = values) {}
|
OtherCard(settings = values, onAction = {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
|
||||||
ElevatedCard(
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(268.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_other_title),
|
text = stringResource(id = R.string.prefs_other_title),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
onAction(SettingsAction.ToggleUtc(it))
|
// Display preferences: UTC clock + night filter
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
||||||
|
onAction(SettingsAction.ToggleUtc(it))
|
||||||
|
}
|
||||||
|
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||||
|
onAction(SettingsAction.ToggleNightMode(it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Radar behavior: sweep animation + sensor control
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||||
|
onAction(SettingsAction.ToggleSweep(it))
|
||||||
|
}
|
||||||
|
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
||||||
|
onAction(SettingsAction.ToggleSensor(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Data management (full width)
|
||||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||||
onAction(SettingsAction.ToggleUpdate(it))
|
onAction(SettingsAction.ToggleUpdate(it))
|
||||||
}
|
}
|
||||||
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
onAction(SettingsAction.ToggleSweep(it))
|
// Compass calibration sliders at the bottom
|
||||||
}
|
CompassOffsetRow(
|
||||||
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
labelResId = R.string.prefs_other_compass_offset,
|
||||||
onAction(SettingsAction.ToggleSensor(it))
|
value = settings.radarCompassOffset,
|
||||||
}
|
range = -180f..180f
|
||||||
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
CompassOffsetRow(
|
||||||
|
labelResId = R.string.prefs_other_compass_offset_elev,
|
||||||
|
value = settings.radarCompassOffsetElev,
|
||||||
|
range = -90f..90f
|
||||||
|
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompassOffsetRow(
|
||||||
|
labelResId: Int,
|
||||||
|
value: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
onValueChange: (Float) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = labelResId))
|
||||||
|
Text(text = "${value.toInt()}°")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
valueRange = range
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -504,6 +585,21 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = labelResId),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@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)
|
||||||
@@ -538,7 +634,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
|||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(268.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -651,13 +746,18 @@ private class SettingsPermissions(
|
|||||||
val launchLocation: () -> Unit,
|
val launchLocation: () -> Unit,
|
||||||
val launchTleImport: () -> Unit,
|
val launchTleImport: () -> Unit,
|
||||||
val launchTransceiverImport: () -> Unit,
|
val launchTransceiverImport: () -> Unit,
|
||||||
val launchBluetooth: () -> Unit
|
val launchBluetooth: () -> Unit,
|
||||||
|
val launchNetwork: () -> Unit,
|
||||||
|
val launchCustomSourcesPermission: () -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberSettingsPermissions(
|
private fun rememberSettingsPermissions(
|
||||||
sendAction: (SettingsAction) -> Unit,
|
sendAction: (SettingsAction) -> Unit,
|
||||||
onBluetoothGranted: () -> Unit
|
onBluetoothGranted: () -> Unit,
|
||||||
|
onNetworkGranted: () -> Unit,
|
||||||
|
onCustomSourcesPermissionGranted: () -> Unit,
|
||||||
|
onCustomSourcesPermissionDenied: () -> Unit
|
||||||
): SettingsPermissions {
|
): SettingsPermissions {
|
||||||
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
||||||
val locationRequest = rememberLauncherForActivityResult(
|
val locationRequest = rememberLauncherForActivityResult(
|
||||||
@@ -669,12 +769,15 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(locationError))
|
else sendAction(SettingsAction.ShowToast(locationError))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val satellitesImportError = stringResource(R.string.prefs_data_import_satellites_error)
|
||||||
|
val transceiversImportError = stringResource(R.string.prefs_data_import_transceivers_error)
|
||||||
|
|
||||||
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString())) }
|
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString(), satellitesImportError)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString())) }
|
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString(), transceiversImportError)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
|
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
|
||||||
@@ -685,6 +788,21 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(bluetoothError))
|
else sendAction(SettingsAction.ShowToast(bluetoothError))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val networkError = stringResource(R.string.prefs_net_perm_error)
|
||||||
|
val networkRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) onNetworkGranted()
|
||||||
|
else sendAction(SettingsAction.ShowToast(networkError))
|
||||||
|
}
|
||||||
|
|
||||||
|
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
onCustomSourcesPermissionGranted()
|
||||||
|
} else {
|
||||||
|
onCustomSourcesPermissionDenied()
|
||||||
|
sendAction(SettingsAction.ShowToast(networkError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return remember {
|
return remember {
|
||||||
SettingsPermissions(
|
SettingsPermissions(
|
||||||
launchLocation = {
|
launchLocation = {
|
||||||
@@ -694,7 +812,21 @@ private fun rememberSettingsPermissions(
|
|||||||
},
|
},
|
||||||
launchTleImport = { tleRequest.launch("*/*") },
|
launchTleImport = { tleRequest.launch("*/*") },
|
||||||
launchTransceiverImport = { transceiversRequest.launch("*/*") },
|
launchTransceiverImport = { transceiversRequest.launch("*/*") },
|
||||||
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) }
|
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) },
|
||||||
|
launchNetwork = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||||
|
networkRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
} else {
|
||||||
|
onNetworkGranted()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
launchCustomSourcesPermission = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||||
|
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
} else {
|
||||||
|
onCustomSourcesPermissionGranted()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -28,7 +28,10 @@ data class PositionSettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class DataSettings(
|
data class DataSettings(
|
||||||
val isUpdating: Boolean, val entriesTotal: Int, val radiosTotal: Int, val timestamp: Long
|
val isUpdating: Boolean,
|
||||||
|
val entriesTotal: Int,
|
||||||
|
val radiosTotal: Int,
|
||||||
|
val timestamp: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SettingsState(
|
data class SettingsState(
|
||||||
@@ -38,7 +41,8 @@ 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 {
|
||||||
@@ -50,8 +54,8 @@ sealed interface SettingsAction {
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
data object UpdateFromWeb : SettingsAction
|
data object UpdateFromWeb : SettingsAction
|
||||||
data class UpdateTLEFromFile(val uri: String) : SettingsAction
|
data class UpdateTLEFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
|
||||||
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction
|
data class UpdateTransceiversFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
|
||||||
data object ClearAllData : SettingsAction
|
data object ClearAllData : SettingsAction
|
||||||
|
|
||||||
// Toggles
|
// Toggles
|
||||||
@@ -61,6 +65,8 @@ sealed interface SettingsAction {
|
|||||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||||
|
data class SetRadarCompassOffset(val value: Float) : SettingsAction
|
||||||
|
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+30
-11
@@ -32,6 +32,7 @@ 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
|
||||||
@@ -47,7 +48,8 @@ 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()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,11 +67,11 @@ class SettingsViewModel(
|
|||||||
settingsRepo.databaseState.collect { state ->
|
settingsRepo.databaseState.collect { state ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
dataSettings = DataSettings(
|
dataSettings = it.dataSettings.copy(
|
||||||
false,
|
isUpdating = false,
|
||||||
state.numberOfSatellites,
|
entriesTotal = state.numberOfSatellites,
|
||||||
state.numberOfRadios,
|
radiosTotal = state.numberOfRadios,
|
||||||
state.updateTimestamp
|
timestamp = state.updateTimestamp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -107,11 +109,11 @@ class SettingsViewModel(
|
|||||||
SettingsAction.DismissPosMessages -> dismissPosMessage()
|
SettingsAction.DismissPosMessages -> dismissPosMessage()
|
||||||
// Data
|
// Data
|
||||||
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
|
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
|
||||||
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
|
is SettingsAction.UpdateTLEFromFile -> runManualImport(action.invalidFileMessage) {
|
||||||
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
|
databaseRepo.updateTLEFromFile(action.uri)
|
||||||
databaseRepo.updateTransceiversFromFile(
|
}
|
||||||
action.uri
|
is SettingsAction.UpdateTransceiversFromFile -> runManualImport(action.invalidFileMessage) {
|
||||||
)
|
databaseRepo.updateTransceiversFromFile(action.uri)
|
||||||
}
|
}
|
||||||
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
|
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
|
||||||
// Toggles
|
// Toggles
|
||||||
@@ -121,6 +123,8 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
|
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
||||||
|
is SettingsAction.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)
|
||||||
@@ -184,12 +188,27 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun runManualImport(importErrorMessage: String, block: suspend () -> Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_uiState.update { it.copy(dataSettings = it.dataSettings.copy(isUpdating = true)) }
|
||||||
|
if (block() == 0) showToast(importErrorMessage)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
_uiState.update { it.copy(dataSettings = it.dataSettings.copy(isUpdating = false)) }
|
||||||
|
println(exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
|
container = container,
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast()
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
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
|
||||||
+13
-13
@@ -1,35 +1,35 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "440"
|
appVersionCode = "445"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.0"
|
appVersionName = "4.4.5"
|
||||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "36"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
minSdk = "24"
|
minSdk = "24"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
jdkVersion = "17"
|
jdkVersion = "21"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.2.1"
|
android-gradle-plugin = "9.3.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.19.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.05.01"
|
compose-bom = "2026.06.01"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.10.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.2"
|
compose-navigation3 = "1.1.5"
|
||||||
|
|
||||||
google-ksp = "2.3.7"
|
google-ksp = "2.3.10"
|
||||||
|
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.10"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
other-okhttp = "5.3.2"
|
other-okhttp = "5.4.0"
|
||||||
other-osmdroid = "6.1.20"
|
other-osmdroid = "6.1.20"
|
||||||
|
|
||||||
test-junit4 = "4.13.2"
|
test-junit4 = "4.13.2"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Mon May 25 11:20:48 BST 2026
|
#Sat Jun 27 12:51:12 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in new issue
Block a user