mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 03:37:21 +00:00
Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
922a68be49 | ||
|
|
44fa4808b6 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 | ||
|
|
4a3332fdb5 | ||
|
|
9e7fdbfe71 | ||
|
|
463df58eb9 | ||
|
|
58b8801c0c | ||
|
|
2581ba92c0 | ||
|
|
e5131ebb72 | ||
|
|
f9accdd517 | ||
|
|
ee3c1e7058 | ||
|
|
a08a28df39 | ||
|
|
759a6de2c6 | ||
|
|
ba5a984c16 | ||
|
|
083b8a2847 | ||
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 | ||
|
|
418a05e343 | ||
|
|
c80e351212 | ||
|
|
a333192df5 | ||
|
|
a7b41c95c7 | ||
|
|
2298d8eea4 | ||
|
|
1813e1bfa4 | ||
|
|
bd09ad22ff | ||
|
|
7e90071c4d | ||
|
|
d1cc944be2 | ||
|
|
fb28283679 | ||
|
|
81ae8772d2 | ||
|
|
571145838d | ||
|
|
017b178f2e | ||
|
|
670444858c | ||
|
|
7bdff5df81 | ||
|
|
df083840ac | ||
|
|
a0279bac17 | ||
|
|
3d630041e1 | ||
|
|
ea92d3c150 | ||
|
|
86ee3dcbac | ||
|
|
43788ba7a7 | ||
|
|
1a3f1bb34c | ||
|
|
998c98267b | ||
|
|
28fa373db0 | ||
|
|
4ca33c37ad | ||
|
|
bd42e643ea | ||
|
|
4baf3821e5 | ||
|
|
d7e1ce11c9 | ||
|
|
5de370983d | ||
|
|
f272e268ab | ||
|
|
e59507a4d3 | ||
|
|
0bae306312 | ||
|
|
65f61a5147 | ||
|
|
1064dc739e | ||
|
|
8aedd38b51 | ||
|
|
ad8f08a577 | ||
|
|
2afce54472 | ||
|
|
8e50fcf8fb | ||
|
|
e11b03d56b | ||
|
|
fa55b82a5a | ||
|
|
d57832b0d4 | ||
|
|
1d0e8921a9 | ||
|
|
23d95b8241 | ||
|
|
8af80866bc | ||
|
|
b9e5ff70f5 | ||
|
|
c176d6ad88 | ||
|
|
5a6b162e4f | ||
|
|
33712c29fc | ||
|
|
eac1e6e273 |
No files matched your search
@@ -4,7 +4,3 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
- package-ecosystem: "bundler"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "never"
|
|
||||||
+5
-1
@@ -1,6 +1,9 @@
|
|||||||
# Built application files
|
# Built application files
|
||||||
*.ap_
|
*.ap_
|
||||||
|
|
||||||
|
# Hermes AI plans and metadata
|
||||||
|
.hermes/
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
# Files for the ART/Dalvik VM
|
||||||
*.dex
|
*.dex
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ out/
|
|||||||
# Gradle files
|
# Gradle files
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
@@ -64,4 +68,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
.hermes/
|
dist/
|
||||||
@@ -8,15 +8,16 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||||
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||||
no tracking, no network required after initial data download.
|
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||||
|
data download.
|
||||||
|
|
||||||
## Architecture & Design
|
## Architecture
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
**Clean Architecture layers:**
|
||||||
@@ -33,11 +34,9 @@ no tracking, no network required after initial data download.
|
|||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
| `feature:settings` | User preferences |
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
**Feature isolation:**
|
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||||
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
|
||||||
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
|
||||||
|
|
||||||
## Build & Platform
|
## Build & Run
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Debug build
|
# Debug build
|
||||||
@@ -51,55 +50,54 @@ no tracking, no network required after initial data download.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||||
|
|
||||||
## Tech Stack
|
## Key Libraries
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||||
- **OkHttp** 5.x for data downloads
|
- **OkHttp** 5.x for TLE downloads
|
||||||
- **OSMDroid** for map rendering
|
- **OSMDroid** for map rendering
|
||||||
- **Kotlin Serialization** for navigation args and parsing
|
- **Kotlin Serialization** for navigation args and data parsing
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||||
|
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||||
|
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||||
|
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||||
|
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||||
|
|
||||||
## Data Formats & Migration
|
## Data Formats & Migration
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
**TLE vs. OMM/CSV format:**
|
||||||
|
|
||||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||||
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
|
||||||
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
|
||||||
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
|
||||||
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
|
||||||
- Existing code already supports transparent source transition without feature changes
|
|
||||||
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
|
||||||
|
|
||||||
## Engineering Heuristics (Lazy = Efficient)
|
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||||
|
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
||||||
|
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
||||||
|
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
||||||
|
|
||||||
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
**Current implementation:**
|
||||||
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||||
1. Does this need to be built now? (YAGNI)
|
- TLE data is downloaded from configured sources and stored in Room database
|
||||||
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||||
3. Does Kotlin/Java stdlib already solve it?
|
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||||
4. Does the Android/platform API already solve it?
|
|
||||||
5. Does an already-installed dependency solve it?
|
|
||||||
6. Can this be simpler (including one-liner simple)?
|
|
||||||
7. Only then: write the minimum code that works.
|
|
||||||
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
|
||||||
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
|
||||||
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
|
||||||
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
|
||||||
- When two options are similar in size, choose the edge-case-correct one.
|
|
||||||
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
|
||||||
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
|
||||||
|
|
||||||
## Bug-Fix Policy
|
**Migration path:**
|
||||||
|
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||||
|
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||||
|
transition to OMM/CSV automatically.
|
||||||
|
|
||||||
- Fix root cause, not just the reported symptom.
|
## Code Style
|
||||||
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
|
||||||
- The smallest correct diff wins only after behavior is understood.
|
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||||
|
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||||
|
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -107,9 +105,12 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
|
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||||
|
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||||
|
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||||
|
|
||||||
## Copilot Working Mode: Code-Only
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,38 @@
|
|||||||
# Look4Sat: Satellite tracker
|
# Look4Sat-BA7OPF
|
||||||
|
|
||||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
[](https://github.com/atsunatsu/Look4Sat/releases)
|
||||||
|
|
||||||
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat)
|
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
|
|
||||||
|
|
||||||
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
## 本仓库特色功能
|
||||||
|
|
||||||
<p float="left">
|
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
|
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
|
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
|
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
|
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||||
</p>
|
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||||
|
|
||||||
### Track satellite passes with ease!
|
## 上游仓库
|
||||||
|
|
||||||
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
|
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
|
||||||
You can search the entire database by NORAD Catalog Number or the satellite's name.
|
|
||||||
|
|
||||||
Orbital positions and passes are calculated relative to your location.\
|
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
|
||||||
To get reliable data make sure to set the station position via the app Settings.
|
- SGP4/SDP4 轨道预测,10 天过境预报
|
||||||
|
- 极坐标雷达图、地面轨迹图
|
||||||
|
- SSTV 图像解码
|
||||||
|
- 无广告、无跟踪、完全离线
|
||||||
|
|
||||||
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\
|
## 许可证
|
||||||
It is now and always will be completely ad-free and open-source.
|
|
||||||
|
|
||||||
## Main features:
|
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
* Predicting satellite positions and passes for up to 10 days
|
|
||||||
* Showing the list of currently active and upcoming satellite passes
|
|
||||||
* Showing the active pass progress, polar trajectory and transceivers info
|
|
||||||
* 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
|
|
||||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
@@ -1,3 +1,23 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = libs.versions.packageName.get()
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "cn.ba7opf.look4sat"
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||||
|
storePassword = "look4sat123"
|
||||||
|
keyAlias = "look4sat"
|
||||||
|
keyPassword = "look4sat123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.app.Application.ActivityLifecycleCallbacks
|
||||||
|
import android.os.Bundle
|
||||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
|
clearAmSatCacheOnAppBackground()
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
// load satellite data on every app start
|
// load satellite data on every app start
|
||||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearAmSatCacheOnAppBackground() {
|
||||||
|
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||||
|
private var startedActivityCount = 0
|
||||||
|
|
||||||
|
override fun onActivityStarted(activity: Activity) {
|
||||||
|
if (startedActivityCount == 0) {
|
||||||
|
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
||||||
|
}
|
||||||
|
startedActivityCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
||||||
|
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||||
|
override fun onActivityResumed(activity: Activity) = Unit
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||||
|
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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
|
||||||
@@ -61,14 +64,16 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
@@ -77,6 +82,8 @@ import com.rtbishop.look4sat.core.presentation.Screen
|
|||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
|
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||||
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
@@ -87,11 +94,16 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
|||||||
fun NavRoot(deeplink: String? = null) {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val deeplinkResolver = DeeplinkResolver()
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||||
LaunchedEffect(deeplink) {
|
LaunchedEffect(deeplink) {
|
||||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
|
val navigateToMap: () -> Unit = {
|
||||||
|
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
|
||||||
|
openMapRequest += 1
|
||||||
|
}
|
||||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||||
@@ -110,13 +122,19 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
rememberViewModelStoreNavEntryDecorator()
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
entry<Screen.Passes> {
|
||||||
|
MainScreen(
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
openMapRequest = openMapRequest,
|
||||||
|
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
entry<RadarDestination> {
|
entry<RadarDestination> {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
RadarDestination(navigateUp = navigateBack)
|
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,19 +142,36 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
fun MainScreen(
|
||||||
|
navigateToRadar: () -> Unit = {},
|
||||||
|
openMapRequest: Int = 0,
|
||||||
|
onOpenMapRequestHandled: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val currentKey = backStack.lastOrNull()
|
||||||
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
|
val navigateToMap: () -> Unit = {
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
backStack.add(Screen.Map)
|
||||||
|
}
|
||||||
|
LaunchedEffect(openMapRequest) {
|
||||||
|
if (openMapRequest > 0) {
|
||||||
|
navigateToMap()
|
||||||
|
onOpenMapRequestHandled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val mutualViewModel: MutualViewModel = viewModel(
|
||||||
val currentKey = backStack.lastOrNull()
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
factory = MutualViewModel.factory(container)
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
)
|
||||||
fadeOut(animationSpec = tween(300))
|
|
||||||
val navItems =
|
|
||||||
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
@@ -150,22 +185,18 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val isSelected = when (currentKey) {
|
val isSelected = when (currentKey) {
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Status -> screen is Screen.Status
|
is Screen.Map -> screen is Screen.Passes
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
item(
|
item(
|
||||||
icon = {
|
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||||
Icon(
|
|
||||||
painter = painterResource(screen.iconResId),
|
|
||||||
contentDescription = stringResource(screen.titleResId)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSelected) return@item
|
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
}
|
}
|
||||||
@@ -190,7 +221,9 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
popTransitionSpec = { fadeTransition },
|
popTransitionSpec = { fadeTransition },
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
entryDecorators = listOf(
|
entryDecorators = listOf(
|
||||||
|
// Required for saving Compose state per entry
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
// Required for ViewModel scoping per entry
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
@@ -198,73 +231,83 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
SatellitesDestination(navigateUp = navigateBack)
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> {
|
||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination(
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
navigateToRadar = { catNum, aosTime ->
|
||||||
navigateToRadar()
|
container.setMutualPassData(MutualPassData())
|
||||||
}
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
},
|
||||||
|
navigateToMap = navigateToMap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Mutual> {
|
||||||
|
MutualScreen(
|
||||||
|
viewModel = mutualViewModel,
|
||||||
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
entry<Screen.Status> { SatStatusDestination() }
|
|
||||||
entry<Screen.Map> { MapDestination() }
|
|
||||||
entry<Screen.Settings> { SettingsDestination() }
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
if (trackingState.isActive) {
|
if (trackingState.isActive) {
|
||||||
TrackingBanner(
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
state = trackingState,
|
val alpha by infiniteTransition.animateFloat(
|
||||||
onClick = {
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
val pass = trackingState.currentPass
|
animationSpec = infiniteRepeatable(
|
||||||
if (pass != null) {
|
animation = tween(1000, easing = LinearEasing),
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
repeatMode = RepeatMode.Reverse
|
||||||
navigateToRadar()
|
), label = "pulseAlpha"
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
|
||||||
val alpha by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 1f,
|
|
||||||
targetValue = 0.4f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulseAlpha"
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Tracking: ${state.currentPass?.name ?: ""}",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
val connections = listOfNotNull(
|
|
||||||
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = connections.joinToString("/"),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
|
|||||||
@@ -45,12 +45,7 @@ interface Look4SatDao {
|
|||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||||
"""
|
|
||||||
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
|
||||||
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
|
|||||||
+4
-2
@@ -67,7 +67,7 @@ class NetworkReporter(
|
|||||||
.replace($$"$AZ", azimuth.toString())
|
.replace($$"$AZ", azimuth.toString())
|
||||||
.replace($$"$EL", el.toString())
|
.replace($$"$EL", el.toString())
|
||||||
.unescapeControlChars()
|
.unescapeControlChars()
|
||||||
write(rotatorSocket, command) { rotatorConnected = false }
|
write(rotatorSocket, command) { resetRotatorConnection() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,5 +154,7 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun String.unescapeControlChars(): String =
|
private fun String.unescapeControlChars(): String =
|
||||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
replace("""\r""", 13.toChar().toString())
|
||||||
|
.replace("""\n""", 10.toChar().toString())
|
||||||
|
.replace("""\t""", 9.toChar().toString())
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
@@ -62,6 +63,9 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
@@ -74,12 +78,19 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||||
|
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||||
|
|
||||||
|
override fun setMutualPassData(data: MutualPassData) {
|
||||||
|
_mutualPassData.value = data
|
||||||
|
}
|
||||||
|
|
||||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||||
|
|
||||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||||
@@ -139,13 +150,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
return SensorsRepo(manager, displayManager)
|
return SensorsRepo(manager, displayManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
|
|
||||||
try {
|
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
||||||
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
|
|
||||||
} catch (_: SecurityException) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
|
|||||||
+114
-7
@@ -1,5 +1,7 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
@@ -7,11 +9,18 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@@ -26,27 +35,125 @@ private data class ApiReport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
class AmSatRepository(
|
||||||
|
private val remoteSource: IRemoteSource,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
) : IAmSatRepository {
|
||||||
|
|
||||||
|
private val statusCacheMutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var statusCache: SatStatusPage? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cacheGeneration = 0
|
||||||
|
|
||||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||||
|
|
||||||
|
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
||||||
|
if (!forceRefresh) statusCache?.let { return it }
|
||||||
|
|
||||||
|
var cachedPage: SatStatusPage? = null
|
||||||
|
val inFlightFetch = statusCacheMutex.withLock {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
val cached = statusCache
|
||||||
|
if (cached != null) {
|
||||||
|
cachedPage = cached
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
||||||
|
fetchStatusFromRemote(cacheGeneration)
|
||||||
|
}.also { statusFetchInFlight = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedPage?.let { return it }
|
||||||
|
val fetch = inFlightFetch ?: return null
|
||||||
|
return try {
|
||||||
|
fetch.await()
|
||||||
|
} finally {
|
||||||
|
statusCacheMutex.withLock {
|
||||||
|
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
||||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
|
||||||
|
|
||||||
val names = parseCatalog(catalogJson)
|
val names = parseCatalog(catalogJson)
|
||||||
val reports = parseReports(reportsJson)
|
val reports = parseReports(reportsJson)
|
||||||
|
|
||||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
if (names.isEmpty() && reports.isEmpty()) return null
|
||||||
|
|
||||||
val statuses = buildStatuses(names, reports, nowSec)
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||||
|
if (generation == cacheGeneration) statusCache = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun prefetchStatus() {
|
||||||
|
fetchStatus(forceRefresh = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearStatusCache() {
|
||||||
|
cacheGeneration += 1
|
||||||
|
statusCache = null
|
||||||
|
statusFetchInFlight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||||
|
val payload = JSONObject().apply {
|
||||||
|
put("name", submission.name)
|
||||||
|
put("report", submission.report)
|
||||||
|
put("callsign", submission.callsign)
|
||||||
|
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||||
|
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||||
|
}
|
||||||
|
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||||
|
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||||
|
val (code, body) = response
|
||||||
|
val errorMessage = parseSubmitError(body)
|
||||||
|
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||||
|
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||||
|
} else {
|
||||||
|
AmSatReportSubmitResult(
|
||||||
|
success = false,
|
||||||
|
message = errorMessage.ifBlank { "HTTP $code" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitError(json: String): String {
|
||||||
|
return try {
|
||||||
|
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitReportId(json: String): String? {
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||||
|
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse catalog JSON to list of satellite names */
|
/** Parse catalog JSON to list of satellite names */
|
||||||
@@ -89,7 +196,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
val byName = reports.groupBy { it.name }
|
val byName = reports.groupBy { it.name }
|
||||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -39,12 +40,14 @@ class DatabaseRepo(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : IDatabaseRepo {
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
|
private val customSourceType = "Other"
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||||
localSource.insertEntries(entries)
|
localSource.insertEntries(entries)
|
||||||
|
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||||
importedCount = entries.size
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -64,19 +67,28 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val settings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
||||||
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
|
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
||||||
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
|
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||||
|
.filterValues { it.isNotBlank() }
|
||||||
|
.mapValues { normalizeUrl(it.value) }
|
||||||
|
.entries
|
||||||
|
.associate { (type, url) -> url to type }
|
||||||
|
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||||
// launch all network requests concurrently
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
// parse fetched data concurrently
|
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
}
|
val type = builtinTypesByUrl[url] ?: customSourceType
|
||||||
|
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||||
|
entries
|
||||||
|
}.distinctBy { it.catnum }
|
||||||
|
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||||
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()
|
||||||
}
|
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
localSource.insertEntries(importedEntries)
|
localSource.insertEntries(importedEntries)
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
@@ -89,6 +101,9 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeUrl(url: String): String =
|
||||||
|
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
val bufferedStream = stream.buffered()
|
val bufferedStream = stream.buffered()
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -79,7 +78,7 @@ class SatelliteRepo(
|
|||||||
aosStartMinute = settings.aosStartMinute,
|
aosStartMinute = settings.aosStartMinute,
|
||||||
aosEndMinute = settings.aosEndMinute,
|
aosEndMinute = settings.aosEndMinute,
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
modes = settingsRepo.selectedSatModes.value
|
modes = settings.selectedModes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +163,7 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newPasses.sortBy { it.aosTime }
|
newPasses.sortBy { it.aosTime }
|
||||||
delay(1000.milliseconds) // Simulate loading time for better UX
|
delay(1000) // Simulate loading time for better UX
|
||||||
_passes.update { newPasses }
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
@@ -182,7 +181,7 @@ class SatelliteRepo(
|
|||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
aosMinute in aosStartMinute..aosEndMinute
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
} else {
|
} else {
|
||||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||||
}
|
}
|
||||||
return if (invertAosTimeWindow) !inRange else inRange
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-16
@@ -38,16 +38,16 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
|
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||||
private val currentQuery = MutableStateFlow("")
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
// Resolve type IDs once when types change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||||
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||||
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = localSource.getIdsWithModes(list)
|
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -56,18 +56,14 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithModes.map { items ->
|
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||||
filterByQuery(items, query).sortedWith(
|
|
||||||
compareByDescending<SatItem> { it.isSelected }
|
|
||||||
.thenBy { it.name }
|
|
||||||
.thenBy { it.catnum }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
override fun getCurrentTypes() = currentTypes.value
|
||||||
|
|
||||||
override fun getModesList() = Sources.satelliteModes
|
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||||
|
removeAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
@@ -77,8 +73,9 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setModes(modes: List<String>) {
|
override suspend fun setTypes(types: List<String>) {
|
||||||
settingsRepo.setSelectedSatModes(modes)
|
currentTypes.value = types
|
||||||
|
settingsRepo.setSelectedTypes(types)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
|
|||||||
+136
-30
@@ -29,10 +29,8 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
@@ -40,6 +38,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.util.Locale
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
@@ -72,7 +73,8 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedSatModes = "selectedSatModes"
|
private val keySelectedTypes = "selectedTypes"
|
||||||
|
private val keySelectedModes = "selectedModes"
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
@@ -90,18 +92,23 @@ class SettingsRepo(
|
|||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
|
private val keyTleUrl = "tleUrl"
|
||||||
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
private val keySatelliteUrls = "satelliteUrls"
|
private val keySatelliteUrls = "satelliteUrls"
|
||||||
private val keyTransceiversUrls = "transceiversUrls"
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
|
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
private val separatorUrl = "\n"
|
private val separatorUrl = "\n"
|
||||||
|
private val legacyCelestrakSatnogsUrl =
|
||||||
|
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -109,10 +116,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedSatModes(modes: List<String>) {
|
override fun setSelectedTypes(types: List<String>) {
|
||||||
val modesString = modes.joinToString(separatorComma)
|
val typesString = types.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedSatModes, modesString) }
|
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||||
_satelliteModeSelection.value = modes
|
_typesSelection.value = types
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -121,10 +128,10 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedSatModes(): List<String> {
|
private fun getSelectedTypes(): List<String> {
|
||||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||||
if (modesString.isNullOrEmpty()) return emptyList()
|
if (typesString.isNullOrEmpty()) return emptyList()
|
||||||
return modesString.split(separatorComma).sorted()
|
return typesString.split(separatorComma)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -139,6 +146,7 @@ class SettingsRepo(
|
|||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
|
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,13 +157,16 @@ class SettingsRepo(
|
|||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
|
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||||
|
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||||
return PassesSettings(
|
return PassesSettings(
|
||||||
showDeepSpace,
|
showDeepSpace,
|
||||||
hoursAhead,
|
hoursAhead,
|
||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow
|
invertAosTimeWindow,
|
||||||
|
selectedModes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -235,6 +246,25 @@ class SettingsRepo(
|
|||||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||||
|
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
||||||
|
val idsSet = mutableSetOf<Int>()
|
||||||
|
types.forEach { type ->
|
||||||
|
val typeString = preferences.getString("type$type", null)
|
||||||
|
val typeIds = if (typeString.isNullOrBlank()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
typeString.split(separatorComma).map { it.toInt() }
|
||||||
|
}
|
||||||
|
idsSet.addAll(typeIds)
|
||||||
|
}
|
||||||
|
return idsSet.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
|
if (type == "All") return
|
||||||
|
val typesString = ids.joinToString(separatorComma)
|
||||||
|
preferences.edit { putString("type$type", typesString) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||||
@@ -344,8 +374,6 @@ class SettingsRepo(
|
|||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
|
||||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -362,9 +390,7 @@ class SettingsRepo(
|
|||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
|
||||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -376,18 +402,69 @@ class SettingsRepo(
|
|||||||
preferences.edit {
|
preferences.edit {
|
||||||
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
|
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
|
||||||
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
|
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
satelliteUrls = preferences.getString(keySatelliteUrls, null)
|
satelliteUrls = getDataSourceUrls(
|
||||||
?.split(separatorUrl)?.filter { it.isNotBlank() }
|
key = keySatelliteUrls,
|
||||||
?: Sources.satelliteDataUrls,
|
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||||
transceiversUrls = preferences.getString(keyTransceiversUrls, null)
|
legacyEnabledKey = keyUseCustomTle,
|
||||||
?.split(separatorUrl)?.filter { it.isNotBlank() }
|
legacyUrlKey = keyTleUrl
|
||||||
?: Sources.transceiversDataUrls
|
).migrateSatnogsTleSource(),
|
||||||
|
transceiversUrls = getDataSourceUrls(
|
||||||
|
key = keyTransceiversUrls,
|
||||||
|
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||||
|
legacyEnabledKey = keyUseCustomTransceivers,
|
||||||
|
legacyUrlKey = keyTransceiversUrl
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun getDataSourceUrls(
|
||||||
|
key: String,
|
||||||
|
defaultUrls: List<String>,
|
||||||
|
legacyEnabledKey: String,
|
||||||
|
legacyUrlKey: String
|
||||||
|
): List<String> {
|
||||||
|
val stored = preferences.getString(key, null)
|
||||||
|
?.split(separatorUrl)
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotBlank() }
|
||||||
|
if (stored != null) return stored
|
||||||
|
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
|
||||||
|
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
|
||||||
|
(defaultUrls + legacyCustomUrl).distinct()
|
||||||
|
} else {
|
||||||
|
defaultUrls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.migrateSatnogsTleSource(): List<String> {
|
||||||
|
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
|
||||||
|
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
|
||||||
|
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
|
||||||
|
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
|
||||||
|
if (celestrakSatnogsIndex < 0) return this
|
||||||
|
|
||||||
|
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
|
||||||
|
preferences.edit {
|
||||||
|
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||||
|
|
||||||
|
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||||
|
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
|
||||||
|
|
||||||
|
private fun normalizeSourceUrl(url: String): String {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -427,16 +504,29 @@ class SettingsRepo(
|
|||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite calculator offset settings
|
||||||
private val keySatelliteOffsets = "satelliteOffsets"
|
private val keySatelliteOffsets = "satelliteOffsets"
|
||||||
|
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String {
|
override fun getSatelliteOffset(catnum: Int): String {
|
||||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||||
return try {
|
val stored = try {
|
||||||
JSONObject(json).optString(catnum.toString(), "")
|
JSONObject(json).optString(catnum.toString(), "")
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
if (stored.isNotEmpty()) return stored
|
||||||
|
|
||||||
|
// Lazy migration from the fork's old UI-layer implementation, which stored
|
||||||
|
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
||||||
|
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
||||||
|
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
||||||
|
if (legacy.isNotEmpty()) {
|
||||||
|
setSatelliteOffset(catnum, legacy)
|
||||||
|
}
|
||||||
|
return legacy
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||||
@@ -446,8 +536,24 @@ class SettingsRepo(
|
|||||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||||
obj.toString()
|
obj.toString()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
"""{"$catnum": "$offset"}"""
|
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
||||||
|
}
|
||||||
|
preferences.edit {
|
||||||
|
putString(keySatelliteOffsets, updated)
|
||||||
|
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
||||||
}
|
}
|
||||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
|
||||||
}
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
private val keyAmSatCallsign = "amSatCallsign"
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String {
|
||||||
|
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) {
|
||||||
|
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,10 @@ import androidx.core.net.toUri
|
|||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -50,8 +52,8 @@ class RemoteSource(
|
|||||||
response.close()
|
response.close()
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
// Return the body stream directly as the caller is responsible for closing it
|
// Return the body stream directly as the caller is responsible for closing it.
|
||||||
// That returns the connection to OkHttp's pool
|
// Closing the stream returns the connection to OkHttp's pool.
|
||||||
response.body.byteStream().buffered()
|
response.body.byteStream().buffered()
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
@@ -90,4 +92,21 @@ class RemoteSource(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
response.code to response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource submitAmSatReport exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class AmSatRepositoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource()
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
val cachedPage = SatStatusPage(
|
||||||
|
fetchedAtUtcMs = 123L,
|
||||||
|
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||||
|
reports = emptyMap()
|
||||||
|
)
|
||||||
|
repository.seedStatusCache(cachedPage)
|
||||||
|
|
||||||
|
val firstPage = repository.fetchStatus()
|
||||||
|
val secondPage = repository.fetchStatus()
|
||||||
|
|
||||||
|
assertSame(cachedPage, firstPage)
|
||||||
|
assertSame(cachedPage, secondPage)
|
||||||
|
assertSame(cachedPage, repository.getCachedStatus())
|
||||||
|
assertEquals(0, remoteSource.catalogRequests)
|
||||||
|
assertEquals(0, remoteSource.reportRequests)
|
||||||
|
|
||||||
|
repository.clearStatusCache()
|
||||||
|
assertNull(repository.getCachedStatus())
|
||||||
|
repository.fetchStatus()
|
||||||
|
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forceRefreshBypassesCachedPage() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource()
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
repository.seedStatusCache(
|
||||||
|
SatStatusPage(
|
||||||
|
fetchedAtUtcMs = 123L,
|
||||||
|
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||||
|
reports = emptyMap()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.fetchStatus(forceRefresh = true)
|
||||||
|
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
|
||||||
|
val prefetch = async { repository.prefetchStatus() }
|
||||||
|
val pageFetch = async { repository.fetchStatus() }
|
||||||
|
prefetch.await()
|
||||||
|
val page = pageFetch.await()
|
||||||
|
|
||||||
|
assertSame(page, repository.getCachedStatus())
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||||
|
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
||||||
|
cacheField.isAccessible = true
|
||||||
|
cacheField.set(this, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||||
|
var catalogRequests = 0
|
||||||
|
var reportRequests = 0
|
||||||
|
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||||
|
|
||||||
|
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? {
|
||||||
|
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||||
|
catalogRequests += 1
|
||||||
|
return """{"data":[{"name":"AO-7"}]}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||||
|
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||||
|
reportRequests += 1
|
||||||
|
return """{"data":[]}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
|
}
|
||||||
+46
-3
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -62,6 +63,7 @@ class DatabaseRepoTest {
|
|||||||
|
|
||||||
assertEquals(1, localSource.insertedEntries.size)
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +101,28 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
|
||||||
|
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[satnogsUrl] = { jamxTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(satnogsUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
||||||
|
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
@@ -111,6 +135,12 @@ class DatabaseRepoTest {
|
|||||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
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
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun jamxTleStream(): InputStream = """
|
||||||
|
JAMX-0825b
|
||||||
|
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||||
|
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeRemoteSource : IRemoteSource {
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
@@ -124,6 +154,8 @@ private class FakeRemoteSource : IRemoteSource {
|
|||||||
override suspend fun getAmSatCatalog(): String? = null
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
private class FakeLocalSource : ILocalSource {
|
||||||
@@ -165,10 +197,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||||
)
|
)
|
||||||
|
|
||||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
@@ -189,9 +221,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
override fun setSelectedSatModes(modes: List<String>) = Unit
|
override fun setSelectedTypes(types: List<String>) = Unit
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
@@ -201,6 +235,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean = true
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
|
satelliteTypeIdsByType[type] = ids
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) {
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
databaseState.value = state
|
databaseState.value = state
|
||||||
@@ -219,6 +258,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
|||||||
-177
@@ -1,177 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatItem
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class SelectionRepoTest {
|
|
||||||
|
|
||||||
private val dispatcher = StandardTestDispatcher()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
|
||||||
val localSource = FakeLocalSource(
|
|
||||||
entries = listOf(
|
|
||||||
SatItem(25544, "ISS (ZARYA)", false),
|
|
||||||
SatItem(40967, "TIANGONG", false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
|
||||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
|
||||||
|
|
||||||
val flow = repository.getEntriesFlow()
|
|
||||||
repository.setModes(listOf("REMOVED_MODE"))
|
|
||||||
|
|
||||||
val items = flow.first()
|
|
||||||
|
|
||||||
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
|
||||||
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
|
||||||
val localSource = FakeLocalSource(
|
|
||||||
entries = listOf(
|
|
||||||
SatItem(44444, "Zeta", false),
|
|
||||||
SatItem(25544, "Alpha", false),
|
|
||||||
SatItem(40967, "Beta", false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
|
||||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
|
||||||
|
|
||||||
val flow = repository.getEntriesFlow()
|
|
||||||
repository.setSelection(listOf(40967), true)
|
|
||||||
|
|
||||||
val items = flow.first()
|
|
||||||
|
|
||||||
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
|
||||||
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeLocalSource(
|
|
||||||
private val entries: List<SatItem>
|
|
||||||
) : ILocalSource {
|
|
||||||
override suspend fun getEntriesTotal(): Int = entries.size
|
|
||||||
|
|
||||||
override suspend fun getEntriesList(): List<SatItem> = entries
|
|
||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
|
||||||
|
|
||||||
override suspend fun deleteEntries() = Unit
|
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getRadiosTotal(): Int = 0
|
|
||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
|
||||||
|
|
||||||
override suspend fun deleteRadios() = Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeSettingsRepo(
|
|
||||||
selectedModes: List<String>
|
|
||||||
) : ISettingsRepo {
|
|
||||||
|
|
||||||
override val appVersionName: String = "test"
|
|
||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
|
||||||
|
|
||||||
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
|
|
||||||
|
|
||||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
|
||||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
|
||||||
)
|
|
||||||
|
|
||||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
|
||||||
|
|
||||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
|
||||||
|
|
||||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
|
||||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
|
||||||
OtherSettings(false, false, false, false, false, false, false, false)
|
|
||||||
)
|
|
||||||
|
|
||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
|
||||||
DataSourcesSettings(emptyList(), emptyList())
|
|
||||||
)
|
|
||||||
|
|
||||||
override val 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
|
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One public AMSAT satellite status report submission. */
|
||||||
|
data class AmSatReportSubmission(
|
||||||
|
val name: String,
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedAtUtcMillis: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of submitting an AMSAT satellite status report. */
|
||||||
|
data class AmSatReportSubmitResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String = "",
|
||||||
|
val reportId: String? = null
|
||||||
|
)
|
||||||
@@ -23,10 +23,10 @@ data class SatDay(
|
|||||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One satellite, 5 days of state */
|
/** One satellite, 3 days of state */
|
||||||
data class SatStatus(
|
data class SatStatus(
|
||||||
val name: String, // "AO-123_[FM]"
|
val name: String, // "AO-123_[FM]"
|
||||||
val days: List<SatDay> // 5 天(新→旧)
|
val days: List<SatDay> // 3 days (newest first)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Overall page parse result */
|
/** Overall page parse result */
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ data class PassesSettings(
|
|||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val aosStartMinute: Int = 0,
|
val aosStartMinute: Int = 0,
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
val invertAosTimeWindow: Boolean = false
|
val invertAosTimeWindow: Boolean = false,
|
||||||
|
val selectedModes: List<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -62,9 +63,7 @@ data class OtherSettings(
|
|||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0,
|
val highElevation: Double = 45.0
|
||||||
val radarCompassOffset: Float = 0f,
|
|
||||||
val radarCompassOffsetElev: Float = 0f
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
|
|||||||
+7
-3
@@ -424,11 +424,15 @@ object CelestialComputer {
|
|||||||
}
|
}
|
||||||
if (sunrise == 0.0) sunrise = daynum
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
daynum = sunrise
|
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||||
|
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||||
|
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||||
|
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||||
|
daynum = sunrise + 0.001
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
||||||
daynum += 0.008
|
daynum += 0.008
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -1,9 +1,23 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
/** AMSAT satellite status data source */
|
/** AMSAT satellite status data source */
|
||||||
interface IAmSatRepository {
|
interface IAmSatRepository {
|
||||||
/** Fetch and parse the AMSAT status page; null on failure */
|
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||||
suspend fun fetchStatus(): SatStatusPage?
|
fun getCachedStatus(): SatStatusPage?
|
||||||
|
|
||||||
|
/** Fetch and parse the AMSAT status page; null on failure. */
|
||||||
|
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
||||||
|
|
||||||
|
/** Warm the foreground-session cache without forcing a network reload. */
|
||||||
|
suspend fun prefetchStatus()
|
||||||
|
|
||||||
|
/** Clear the foreground-session status cache. */
|
||||||
|
fun clearStatusCache()
|
||||||
|
|
||||||
|
/** Submit a public AMSAT satellite status report. */
|
||||||
|
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||||
}
|
}
|
||||||
+23
-2
@@ -16,12 +16,13 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface IMainContainer {
|
interface IMainContainer {
|
||||||
val appScope: CoroutineScope
|
val appScope: CoroutineScope
|
||||||
@@ -31,6 +32,8 @@ interface IMainContainer {
|
|||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
|
fun setMutualPassData(data: MutualPassData)
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
@@ -40,9 +43,27 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class MutualPassData(
|
||||||
|
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
||||||
|
val trackSamples: List<TrackSampleData> = emptyList(),
|
||||||
|
val startTime: Long = 0L,
|
||||||
|
val endTime: Long = 0L,
|
||||||
|
val maxElev: Double = 10.0,
|
||||||
|
val labelA: String = "你",
|
||||||
|
val labelB: String = "友台"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Minimal track sample for cross-module sharing (angles in degrees). */
|
||||||
|
data class TrackSampleData(
|
||||||
|
val time: Long = 0L,
|
||||||
|
val azimuthA: Double,
|
||||||
|
val elevationA: Double,
|
||||||
|
val azimuthB: Double,
|
||||||
|
val elevationB: Double
|
||||||
|
)
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
fun getMainContainer(): IMainContainer
|
fun getMainContainer(): IMainContainer
|
||||||
}
|
}
|
||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentModes(): List<String>
|
fun getCurrentTypes(): List<String>
|
||||||
fun getModesList(): List<String>
|
fun getTypesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||||
suspend fun setModes(modes: List<String>)
|
suspend fun setTypes(types: List<String>)
|
||||||
suspend fun setQuery(query: String)
|
suspend fun setQuery(query: String)
|
||||||
suspend fun setSelection(selectAll: Boolean)
|
suspend fun setSelection(selectAll: Boolean)
|
||||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||||
|
|||||||
+9
-2
@@ -32,9 +32,9 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
val selectedSatModes: StateFlow<List<String>>
|
val selectedTypes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedSatModes(modes: List<String>)
|
fun setSelectedTypes(types: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -51,6 +51,8 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Database update settings
|
//region # Database update settings
|
||||||
val databaseState: StateFlow<DatabaseState>
|
val databaseState: StateFlow<DatabaseState>
|
||||||
|
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
||||||
|
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
||||||
fun updateDatabaseState(state: DatabaseState)
|
fun updateDatabaseState(state: DatabaseState)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -80,4 +82,9 @@ interface ISettingsRepo {
|
|||||||
fun getSatelliteOffset(catnum: Int): String
|
fun getSatelliteOffset(catnum: Int): String
|
||||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
fun getAmSatCallsign(): String
|
||||||
|
fun setAmSatCallsign(callsign: String)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -24,4 +24,5 @@ interface IRemoteSource {
|
|||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): InputStream?
|
||||||
suspend fun getAmSatCatalog(): String?
|
suspend fun getAmSatCatalog(): String?
|
||||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||||
|
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||||
}
|
}
|
||||||
@@ -18,25 +18,40 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
val satelliteDataUrls = listOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"db.satnogs.org/api/tle/?format=3le",
|
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||||
"amsat.org/tle/current/nasabare.txt",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
"mmccants.org/tles/classfd.zip",
|
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||||
"r4uab.ru/satonline.txt",
|
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||||
"live.ariss.org/iss.txt"
|
"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",
|
||||||
|
"CelesTrak 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",
|
||||||
|
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||||
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = listOf(
|
val transceiversDataUrls = mapOf(
|
||||||
"db.satnogs.org/api/transmitters/?format=json&status=active",
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||||
"r4uab.ru/transmitters.json"
|
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||||
)
|
|
||||||
val satelliteModes = listOf(
|
|
||||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
|
||||||
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
|
|
||||||
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
|
|
||||||
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
|
|
||||||
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
|
|
||||||
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
|
|
||||||
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
val line1 = tle[1]
|
val line1 = tle[1]
|
||||||
val line2 = tle[2]
|
val line2 = tle[2]
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = tle[0].trim().removePrefix("0 "),
|
name = tle[0].trim(),
|
||||||
epoch = line1.substring(18, 32).toDouble(),
|
epoch = line1.substring(18, 32).toDouble(),
|
||||||
meanmo = line2.substring(52, 63).toDouble(),
|
meanmo = line2.substring(52, 63).toDouble(),
|
||||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||||
|
|||||||
+58
-26
@@ -16,18 +16,28 @@ import java.util.Locale
|
|||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
*
|
*
|
||||||
* For a linear (passband) transponder, uplink and downlink frequencies are
|
* The full physical path:
|
||||||
* 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)
|
* TX→RX (uplink → downlink):
|
||||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
* ① 地面发射 f_tx
|
||||||
|
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
|
||||||
|
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
|
||||||
|
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
|
||||||
|
*
|
||||||
|
* RX→TX (downlink → uplink):
|
||||||
|
* ④ 地面听到 f_rx
|
||||||
|
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
|
||||||
|
* ② 卫星收到 = 逆passband映射(③)
|
||||||
|
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
|
||||||
|
*
|
||||||
|
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
|
||||||
*/
|
*/
|
||||||
object DopplerFrequencyCalculator {
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* uplink frequency the user should transmit.
|
||||||
|
* Full path: ④→③→②→①
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlink(
|
fun computeUplinkFromDownlink(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -35,17 +45,22 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
// ④→③ 逆下行多普勒:卫星转发的频率
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
val satTx = orbitalPos.getUplinkFreq(downlinkHz)
|
||||||
|
// ③→② 逆 passband 映射
|
||||||
|
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||||
|
// ②→① 逆上行多普勒:地面应发射的频率
|
||||||
|
return orbitalPos.getUplinkFreq(satRx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* uplink frequency the user should transmit, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ④→③→②→①
|
||||||
*
|
*
|
||||||
* The user-entered downlink frequency already includes the offset, so subtract
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
* it before mapping the downlink passband position back to the uplink.
|
* it before the inverse downlink Doppler.
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlinkWithOffset(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -54,13 +69,20 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
|
||||||
|
// ③ 去掉 offset(offset 在卫星本地频率域)
|
||||||
|
val satTx = satTxWithOffset - offsetHz
|
||||||
|
// ③→② 逆 passband 映射
|
||||||
|
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||||
|
// ②→① 逆上行多普勒:地面应发射的频率
|
||||||
|
return orbitalPos.getUplinkFreq(satRx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* downlink frequency the user will hear.
|
||||||
|
* Full path: ①→②→③→④
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplink(
|
fun computeDownlinkFromUplink(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -68,14 +90,19 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||||
|
// ②→③ passband 映射
|
||||||
|
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||||
|
// ③→④ 下行多普勒:地面听到的
|
||||||
|
return orbitalPos.getDownlinkFreq(satTx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* downlink frequency the user will hear, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ①→②→③→④
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplinkWithOffset(
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -84,8 +111,13 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||||
|
// ②→③ passband 映射
|
||||||
|
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||||
|
// ③ 加上 offset(offset 在卫星本地频率域)
|
||||||
|
// ③→④ 下行多普勒:地面听到的
|
||||||
|
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if this transponder supports linear passband mapping. */
|
/** True if this transponder supports linear passband mapping. */
|
||||||
@@ -102,8 +134,8 @@ object DopplerFrequencyCalculator {
|
|||||||
* True for the radio entry that should drive the standalone Calculator page.
|
* 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
|
* 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
|
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||||
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
*/
|
*/
|
||||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
if (!isLinearTransponder(transponder)) return false
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|||||||
@@ -41,21 +41,6 @@ fun Double.round(decimals: Int): Double {
|
|||||||
return kotlin.math.round(this * multiplier) / multiplier
|
return kotlin.math.round(this * multiplier) / multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String.aprsPasscode(): Int {
|
|
||||||
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
|
||||||
var hash = 0x73E2
|
|
||||||
var i = 0
|
|
||||||
while (i < callsign.length) {
|
|
||||||
hash = hash xor (callsign[i].code shl 8)
|
|
||||||
i++
|
|
||||||
if (i < callsign.length) {
|
|
||||||
hash = hash xor callsign[i].code
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hash and 0x7FFF
|
|
||||||
}
|
|
||||||
|
|
||||||
//fun String.getHash(type: String = "SHA-256"): String {
|
//fun String.getHash(type: String = "SHA-256"): String {
|
||||||
// val hexChars = "0123456789ABCDEF"
|
// val hexChars = "0123456789ABCDEF"
|
||||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -240,10 +239,4 @@ class DataParserTest {
|
|||||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `check APRS passcode calculation`() {
|
|
||||||
assert("M7LNB".aprsPasscode() == 12443)
|
|
||||||
assert("N0CALL".aprsPasscode() == 13023)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+11
-9
@@ -3,11 +3,7 @@ package com.rtbishop.look4sat.core.domain
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.*
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DopplerFrequencyCalculatorTest {
|
class DopplerFrequencyCalculatorTest {
|
||||||
@@ -166,6 +162,8 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
val orbitalPos = pos(0.0)
|
val orbitalPos = pos(0.0)
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
|
assertTrue(uplink!! > 0)
|
||||||
|
// With zero Doppler, result equals mapDownlinkToUplink output
|
||||||
assertEquals(145_200_000L, uplink)
|
assertEquals(145_200_000L, uplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,23 +178,24 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(7.0)
|
val orbitalPos = pos(7.0) // ~7 km/s receding
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
|
// Uplink freq should be Doppler shifted UP (compensating for receding)
|
||||||
assertTrue(uplink!! > 145_200_000L)
|
assertTrue(uplink!! > 145_200_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
|
||||||
val orbitalPos = pos()
|
val orbitalPos = pos()
|
||||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||||
assertNull(result)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
|
||||||
val orbitalPos = pos()
|
val orbitalPos = pos()
|
||||||
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||||
assertNull(result)
|
assertNull(result)
|
||||||
@@ -255,11 +254,13 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
val orbitalPos = pos(0.0)
|
val orbitalPos = pos(0.0)
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
|
// Inverted: offset from high end → maps to high end of uplink
|
||||||
assertEquals(145_300_000L, uplink)
|
assertEquals(145_300_000L, uplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_roundTrip() {
|
fun computeUplinkFromDownlink_roundTrip() {
|
||||||
|
// downlink → uplink → downlink should round-trip
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(3.5)
|
val orbitalPos = pos(3.5)
|
||||||
val originalDownlink = 435_250_000L
|
val originalDownlink = 435_250_000L
|
||||||
@@ -267,6 +268,7 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||||
assertNotNull(roundTripDownlink)
|
assertNotNull(roundTripDownlink)
|
||||||
|
// Doppler round-trip: small residual due to freq-dependent Doppler
|
||||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-58
@@ -27,18 +27,13 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -76,8 +71,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
@@ -150,7 +143,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -164,16 +157,13 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
|
||||||
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = pass.name,
|
text = pass.name,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(end = 6.dp)
|
.padding(end = 6.dp)
|
||||||
.infiniteMarquee(),
|
.infiniteMarquee(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -219,11 +209,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun displayLocale(): Locale {
|
|
||||||
val locale = Locale.getDefault()
|
|
||||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -239,13 +224,8 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IconCard(
|
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||||
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||||
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
|
||||||
) {
|
|
||||||
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
|
||||||
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||||
}
|
}
|
||||||
@@ -305,6 +285,7 @@ fun InfoDialog(
|
|||||||
title: String,
|
title: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAccept: () -> Unit,
|
onAccept: () -> Unit,
|
||||||
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||||
@@ -323,6 +304,10 @@ fun InfoDialog(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
extraAction?.let {
|
||||||
|
it()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
@@ -330,52 +315,51 @@ fun InfoDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConfirmDialog(
|
fun SharedDialog(
|
||||||
title: String,
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
onCancel: () -> Unit,
|
|
||||||
onAccept: () -> Unit,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
) {
|
||||||
DialogShell(onDismissRequest = onCancel) { padding ->
|
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SharedDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onCancel: (() -> Unit)? = null,
|
||||||
|
onAccept: (() -> Unit)? = null,
|
||||||
|
titleFontSize: Int = 16,
|
||||||
|
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||||
|
content: @Composable (padding: Dp) -> Unit
|
||||||
|
) {
|
||||||
|
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = padding, top = padding, end = padding)
|
.padding(start = padding, top = padding, end = padding)
|
||||||
) {
|
) {
|
||||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
if (onCancel != null) {
|
||||||
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
fontSize = 16.sp,
|
fontSize = titleFontSize.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = titleTextAlign,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(horizontal = padding)
|
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||||
)
|
)
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
if (onAccept != null) {
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
content()
|
content(padding)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
|
||||||
InfoDialog(
|
|
||||||
title = stringResource(R.string.pass_whatsnew_title),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
onAccept = onDismiss
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.pass_whatsnew_message),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,9 +371,6 @@ private fun DialogShell(
|
|||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
|
||||||
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
|
||||||
val maxSheetHeight = containerHeight - statusBarHeight
|
|
||||||
val stopSheetFling = remember {
|
val stopSheetFling = remember {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
@@ -407,7 +388,7 @@ private fun DialogShell(
|
|||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
shape = MaterialTheme.shapes.medium,
|
||||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -415,7 +396,6 @@ private fun DialogShell(
|
|||||||
verticalArrangement = Arrangement.spacedBy(padding),
|
verticalArrangement = Arrangement.spacedBy(padding),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = maxSheetHeight)
|
|
||||||
.nestedScroll(stopSheetFling)
|
.nestedScroll(stopSheetFling)
|
||||||
) {
|
) {
|
||||||
content(padding)
|
content(padding)
|
||||||
|
|||||||
+6
-3
@@ -24,16 +24,19 @@ import kotlinx.serialization.Serializable
|
|||||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
|
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Mutual : Screen(R.drawable.ic_radio_tower, R.string.nav_mutual)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object AMSAT : Screen(R.drawable.ic_satellite_alt, R.string.nav_amsat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
android:width="24dp"
|
<path android:fillColor="@android:color/white" android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
|
||||||
</vector>
|
</vector>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="15"
|
|
||||||
android:viewportHeight="15">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="96"
|
||||||
|
android:viewportHeight="96">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M12,10A2,2 0 0,1 14,12C14,12.5 13.82,12.94 13.53,13.29L16.7,22H14.57L12,14.93L9.43,22H7.3L10.47,13.29C10.18,12.94 10,12.5 10,12A2,2 0 0,1 12,10M12,8A4,4 0 0,0 8,12C8,12.5 8.1,13 8.28,13.46L7.4,15.86C6.53,14.81 6,13.47 6,12A6,6 0 0,1 12,6A6,6 0 0,1 18,12C18,13.47 17.47,14.81 16.6,15.86L15.72,13.46C15.9,13 16,12.5 16,12A4,4 0 0,0 12,8M12,4A8,8 0 0,0 4,12C4,14.36 5,16.5 6.64,17.94L5.92,19.94C3.54,18.11 2,15.23 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12C22,15.23 20.46,18.11 18.08,19.94L17.36,17.94C19,16.5 20,14.36 20,12A8,8 0 0,0 12,4Z" />
|
||||||
|
</vector>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="#FF000000"
|
||||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||||
</vector>
|
</vector>
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="128"
|
|
||||||
android:viewportHeight="128">
|
|
||||||
<path
|
|
||||||
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- AMSAT tab icon provided by rt-bishop (upstream author), original SVG asset -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.242"
|
||||||
|
android:scaleY="0.242"
|
||||||
|
android:translateX="0.3"
|
||||||
|
android:translateY="-230.5">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 21.275553,967.35933 a 2.0000048,2.0000048 0 0 0 -1.1875,0.59375 l -13.5000003,13.5 a 2.0000048,2.0000048 0 0 0 0,2.8125 l 18.5625003,18.59382 a 2.0000048,2.0000048 0 0 0 1.2813,0.5625 l 5.5312,0.4375 -4.0312,4.0312 a 2.0000048,2.0000048 0 0 0 0,2.8126 l 13.6874,13.7187 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 4.0312,-4.0312 0.4376,5.5312 a 2.0000048,2.0000048 0 0 0 0.5624,1.2813 l 18.5938,18.5625 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 13.468705,-13.4688 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -18.562505,-18.5938 a 2.0000048,2.0000048 0 0 0 -1.2812,-0.5625 l -5.5313,-0.4375 4.0313,-4.0313 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -13.6876,-13.68757 a 2.0000048,2.0000048 0 0 0 -2.8437,0 l -4.0313,4.03125 -0.4374,-5.53125 a 2.0000048,2.0000048 0 0 0 -0.5626,-1.28125 l -18.5937,-18.5625 a 2.0000048,2.0000048 0 0 0 -1.4375,-0.59375 2.0000048,2.0000048 0 0 0 -0.1875,0 z m 0.2187,4.8125 16.6563,16.65625 0.5625,7.46875 -3.7812,3.78127 -7.4688,-0.59377 -16.6562,-16.625 z m 34.5626,41.46887 7.4687,0.5937 16.656305,16.625 -10.687605,10.6875 -16.625,-16.6562 -0.5937,-7.4688 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,966.18283 a 2.000005,2.000005 0 1 0 0,3.99969 c 7.9999,0 15.9734,3.03851 22.0884,9.15344 6.103605,6.10359 9.143805,14.08909 9.143805,22.07844 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-9.00692 -3.4462,-18.03775 -10.315,-24.90657 -6.877605,-6.8775 -15.900305,-10.325 -24.916605,-10.325 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,958.36533 a 2.000005,2.000005 0 1 0 0,3.99969 c 10.0021,0 19.9781,3.79987 27.613705,11.43562 7.6352,7.63517 11.4357,17.62207 11.4357,27.61376 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-11.00842 -4.207,-22.04171 -12.6072,-30.44188 -8.399505,-8.3996 -19.424005,-12.60719 -30.441905,-12.60719 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,973.94033 a 2.000005,2.000005 0 1 0 0,3.99938 c 6.0191,0 12.0105,2.279 16.6031,6.87156 4.5926,4.59255 6.881605,10.59683 6.881605,16.60313 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-7.02618 -2.6961,-14.07429 -8.053105,-19.43126 -5.3571,-5.35696 -12.398,-8.04281 -19.4313,-8.04281 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,981.77783 a 2.000005,2.000005 0 1 0 0,3.99938 c 4.0058,0 7.9968,1.51836 11.0578,4.57937 3.061,3.06099 4.5794,7.05193 4.5794,11.05782 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-5.02276 -1.9251,-10.06035 -5.7507,-13.88594 -3.8256,-3.82559 -8.8631,-5.75063 -13.8859,-5.75063 z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
File renamed without changes.
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
||||||
|
</vector>
|
||||||
@@ -13,16 +13,12 @@
|
|||||||
<string name="nav_prefs">Ajustes</string>
|
<string name="nav_prefs">Ajustes</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Modos: %s</string>
|
<string name="sat_type_hint">Tipo: %s</string>
|
||||||
<string name="sat_type_title">Seleccionar modos</string>
|
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
||||||
<string name="sat_search_hint">Id - Nombre</string>
|
<string name="sat_search_hint">Id - Nombre</string>
|
||||||
<string name="sat_search_clear">Vaciar</string>
|
<string name="sat_search_clear">Vaciar</string>
|
||||||
<string name="sat_clear_all">Vaciar todo</string>
|
<string name="sat_clear_all">Vaciar todo</string>
|
||||||
<string name="sat_select_all">Seleccionar todo</string>
|
<string name="sat_select_all">Seleccionar todo</string>
|
||||||
<string name="sat_group_selected">Seleccionados</string>
|
|
||||||
<string name="sat_group_available">Disponibles</string>
|
|
||||||
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
|
|
||||||
<string name="sat_group_available_count">Disponibles (%1$d)</string>
|
|
||||||
<string name="sat_empty_list_message">
|
<string name="sat_empty_list_message">
|
||||||
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
||||||
<string name="sat_warning_title">Alarma\!</string>
|
<string name="sat_warning_title">Alarma\!</string>
|
||||||
@@ -30,7 +26,7 @@
|
|||||||
Esta aplicación incluye más de 9000 satélites.
|
Esta aplicación incluye más de 9000 satélites.
|
||||||
No tiene sentido rastrearlos todos a la vez.
|
No tiene sentido rastrearlos todos a la vez.
|
||||||
\n\nIntenta siempre limitar la lista a los que te
|
\n\nIntenta siempre limitar la lista a los que te
|
||||||
interesen mediante la búsqueda y el selector de modos.</string>
|
interesen mediante la búsqueda y el selector de tipos.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
<string name="pass_filter_title">Filtrar pases</string>
|
||||||
@@ -39,7 +35,7 @@
|
|||||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||||
<string name="pass_modes_title">Seleccionar modos</string>
|
<string name="pass_modes_title">Tipo de modulación</string>
|
||||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -100,11 +96,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
|
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
|
||||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
||||||
<string name="prefs_station_title">Ubicación de la estación</string>
|
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
|
||||||
<string name="prefs_station_lat_text">Latitud de estación</string>
|
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
||||||
<string name="prefs_station_lon_text">Longitud de estación</string>
|
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
||||||
<string name="prefs_locator_title">Localizador QTH</string>
|
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
||||||
<string name="prefs_locator_text">Posición por localizador</string>
|
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Datos satelitales</string>
|
<string name="prefs_data_title">Datos satelitales</string>
|
||||||
<string name="prefs_data_entries">Satélites: %s</string>
|
<string name="prefs_data_entries">Satélites: %s</string>
|
||||||
@@ -114,52 +110,46 @@
|
|||||||
<string name="prefs_data_clear">Vaciar</string>
|
<string name="prefs_data_clear">Vaciar</string>
|
||||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
||||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||||
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||||
|
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Salida de datos</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Red</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">Control de radio</string>
|
<string name="prefs_net_title">Salida de datos de red</string>
|
||||||
<string name="rc_settings_title">Control CAT</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="rc_radio_model">Modelo de radio</string>
|
|
||||||
<string name="rc_tx_device_hint">Dirección BT TX</string>
|
|
||||||
<string name="rc_rx_device_hint">Dirección BT RX</string>
|
|
||||||
<string name="rc_tx_name_hint">Nombre radio TX</string>
|
|
||||||
<string name="rc_rx_name_hint">Nombre radio RX</string>
|
|
||||||
<string name="rc_enable_switch">Activar CAT</string>
|
|
||||||
|
|
||||||
<string name="prefs_net_title">Salida de red</string>
|
|
||||||
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
|
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||||
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Salida Bluetooth</string>
|
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
|
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
|
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
||||||
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
|
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
|
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
||||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Otros</string>
|
<string name="prefs_other_title">Otros ajustes</string>
|
||||||
<string name="prefs_other_switch_utc">Hora UTC</string>
|
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||||
<string name="prefs_other_switch_sweep">Animación radar</string>
|
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Sensores</string>
|
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||||
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
|||||||
@@ -13,23 +13,19 @@
|
|||||||
<string name="nav_prefs">Настройки</string>
|
<string name="nav_prefs">Настройки</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Режимы: %s</string>
|
<string name="sat_type_hint">Тип: %s</string>
|
||||||
<string name="sat_type_title">Выберите режимы</string>
|
<string name="sat_type_title">Выберите тип спутника</string>
|
||||||
<string name="sat_search_hint">Id - Название</string>
|
<string name="sat_search_hint">Id - Название</string>
|
||||||
<string name="sat_search_clear">Очистить</string>
|
<string name="sat_search_clear">Очистить</string>
|
||||||
<string name="sat_clear_all">Очистить</string>
|
<string name="sat_clear_all">Очистить</string>
|
||||||
<string name="sat_select_all">Выбрать</string>
|
<string name="sat_select_all">Выбрать</string>
|
||||||
<string name="sat_group_selected">Выбранные</string>
|
|
||||||
<string name="sat_group_available">Доступные</string>
|
|
||||||
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
|
|
||||||
<string name="sat_group_available_count">Доступные (%1$d)</string>
|
|
||||||
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||||
<string name="sat_warning_title">Внимание\!</string>
|
<string name="sat_warning_title">Внимание\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
В этом приложении перечислено более 9000 спутников.
|
В этом приложении перечислено более 9000 спутников.
|
||||||
Отслеживать их все одновременно бессмысленно.
|
Отслеживать их все одновременно бессмысленно.
|
||||||
\n\nВсегда старайтесь сузить список до тех,
|
\n\nВсегда старайтесь сузить список до тех,
|
||||||
которые вас интересуют, используя поиск и выбор режимов.</string>
|
которые вас интересуют, используя поиск и селектор типов.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||||
@@ -38,7 +34,7 @@
|
|||||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||||
<string name="pass_modes_title">Выберите режимы</string>
|
<string name="pass_modes_title">Выберите тип модуляции</string>
|
||||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||||
<string name="pass_altitude">Высота: %d км</string>
|
<string name="pass_altitude">Высота: %d км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -99,11 +95,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
||||||
<string name="prefs_station_title">Положение станции</string>
|
<string name="prefs_station_title">Настройки местоположения</string>
|
||||||
<string name="prefs_station_lat_text">Широта станции</string>
|
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
|
||||||
<string name="prefs_station_lon_text">Долгота станции</string>
|
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
|
||||||
<string name="prefs_locator_title">Локатор QTH</string>
|
<string name="prefs_locator_title">Настройки локатора QTH</string>
|
||||||
<string name="prefs_locator_text">Позиция по локатору</string>
|
<string name="prefs_locator_text">Введите позицию через локатор</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Данные спутников</string>
|
<string name="prefs_data_title">Данные спутников</string>
|
||||||
<string name="prefs_data_entries">Спутников: %s</string>
|
<string name="prefs_data_entries">Спутников: %s</string>
|
||||||
@@ -113,30 +109,23 @@
|
|||||||
<string name="prefs_data_clear">Очистить</string>
|
<string name="prefs_data_clear">Очистить</string>
|
||||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
||||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||||
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Свои источники</string>
|
<string name="prefs_data_sources_title">Свои источники данных</string>
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||||
|
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Вывод</string>
|
<string name="prefs_data_output_title">Вывод данных</string>
|
||||||
<string name="prefs_net_output">Сеть</string>
|
<string name="prefs_net_output">Сеть</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">Управление радио</string>
|
<string name="prefs_net_title">Вывод сетевых данных</string>
|
||||||
|
|
||||||
<string name="rc_settings_title">CAT управление</string>
|
|
||||||
<string name="rc_radio_model">Модель радио</string>
|
|
||||||
<string name="rc_tx_device_hint">BT адрес TX</string>
|
|
||||||
<string name="rc_rx_device_hint">BT адрес RX</string>
|
|
||||||
<string name="rc_tx_name_hint">Имя радио TX</string>
|
|
||||||
<string name="rc_rx_name_hint">Имя радио RX</string>
|
|
||||||
<string name="rc_enable_switch">Включить CAT</string>
|
|
||||||
|
|
||||||
<string name="prefs_net_title">Сетевой вывод</string>
|
|
||||||
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||||
@@ -144,22 +133,22 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Формат</string>
|
<string name="prefs_net_frequency_format_hint">Формат</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вывод Bluetooth</string>
|
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
|
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
|
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Формат</string>
|
<string name="prefs_bt_rotator_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
|
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
|
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Прочее</string>
|
<string name="prefs_other_title">Другие настройки</string>
|
||||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||||
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||||
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
|||||||
@@ -13,23 +13,19 @@
|
|||||||
<string name="nav_prefs">පසුතල</string>
|
<string name="nav_prefs">පසුතල</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">මාදිලි: %s</string>
|
<string name="sat_type_hint">වර්ගය: %s</string>
|
||||||
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
||||||
<string name="sat_search_hint">Id - නම</string>
|
<string name="sat_search_hint">Id - නම</string>
|
||||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||||
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
||||||
<string name="sat_group_selected">තෝරාගත්</string>
|
|
||||||
<string name="sat_group_available">තිබෙන</string>
|
|
||||||
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
|
|
||||||
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
|
|
||||||
<string name="sat_empty_list_message">
|
<string name="sat_empty_list_message">
|
||||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||||
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
@@ -39,7 +35,7 @@
|
|||||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||||
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
|
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
||||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -100,11 +96,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
||||||
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
||||||
<string name="prefs_station_title">පොළ පිහිටීම</string>
|
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
|
||||||
<string name="prefs_station_lat_text">පොළ අක්ෂාංශය</string>
|
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්ෂාංශය සකසන්න</string>
|
||||||
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
|
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
|
||||||
<string name="prefs_locator_title">QTH locator</string>
|
<string name="prefs_locator_title">QTH locator පසුතල</string>
|
||||||
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
|
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
||||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
||||||
@@ -114,52 +110,46 @@
|
|||||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
||||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
||||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||||
<string name="prefs_data_import_satellites_error">චන්ද්රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||||
|
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">දත්ත ප්රතිදානය</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">ජාලය</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
|
|
||||||
<string name="rc_settings_title">CAT පාලනය</string>
|
|
||||||
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
|
|
||||||
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
|
|
||||||
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
|
|
||||||
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
|
|
||||||
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
|
|
||||||
<string name="rc_enable_switch">CAT සබල කරන්න</string>
|
|
||||||
|
|
||||||
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
||||||
<string name="prefs_net_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
|
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
|
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth ප්රතිදානය</string>
|
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
||||||
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
|
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
|
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
|
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
|
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">වෙනත්</string>
|
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||||
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||||
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||||
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
|||||||
@@ -14,23 +14,19 @@
|
|||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Modlar: %s</string>
|
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||||
<string name="sat_type_title">Modları seçin</string>
|
<string name="sat_type_title">Uydu türü seçin</string>
|
||||||
<string name="sat_search_hint">Id - Ad</string>
|
<string name="sat_search_hint">Id - Ad</string>
|
||||||
<string name="sat_search_clear">Temizle</string>
|
<string name="sat_search_clear">Temizle</string>
|
||||||
<string name="sat_clear_all">Tümünü temizle</string>
|
<string name="sat_clear_all">Tümünü temizle</string>
|
||||||
<string name="sat_select_all">Tümünü seç</string>
|
<string name="sat_select_all">Tümünü seç</string>
|
||||||
<string name="sat_group_selected">Seçilenler</string>
|
|
||||||
<string name="sat_group_available">Kullanılabilir</string>
|
|
||||||
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
|
|
||||||
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
|
|
||||||
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
|
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
|
||||||
<string name="sat_warning_title">Uyarı\!</string>
|
<string name="sat_warning_title">Uyarı\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
||||||
Hepsini aynı anda takip etmek anlamsızdır.
|
Hepsini aynı anda takip etmek anlamsızdır.
|
||||||
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
||||||
daraltmak için arama ve mod seçiciyi kullanın.</string>
|
daraltmak için arama ve tür seçiciyi kullanın.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||||
@@ -40,7 +36,7 @@
|
|||||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Modları seçin</string>
|
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
@@ -131,11 +127,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
||||||
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
||||||
<string name="prefs_station_title">İstasyon konumu</string>
|
<string name="prefs_station_title">İstasyon konumu ayarları</string>
|
||||||
<string name="prefs_station_lat_text">İstasyon enlemi</string>
|
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
|
||||||
<string name="prefs_station_lon_text">İstasyon boylamı</string>
|
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
|
||||||
<string name="prefs_locator_title">QTH konumlandırıcı</string>
|
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
|
||||||
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
|
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Uydu verisi</string>
|
<string name="prefs_data_title">Uydu verisi</string>
|
||||||
<string name="prefs_data_entries">Uydular: %s</string>
|
<string name="prefs_data_entries">Uydular: %s</string>
|
||||||
@@ -145,30 +141,34 @@
|
|||||||
<string name="prefs_data_clear">Temizle</string>
|
<string name="prefs_data_clear">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
<string name="prefs_data_import_satellites_error">Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin.</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel kaynaklar</string>
|
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||||
|
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Veri çıkışı</string>
|
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||||
<string name="prefs_net_output">Ağ</string>
|
<string name="prefs_net_output">Ağ</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
||||||
<string name="rc_settings_title">CAT kontrolü</string>
|
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
|
||||||
<string name="rc_radio_model">Radyo Modeli</string>
|
<string name="rc_radio_model">Radyo Modeli</string>
|
||||||
<string name="rc_tx_device_hint">TX BT adresi</string>
|
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
|
||||||
<string name="rc_rx_device_hint">RX BT adresi</string>
|
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
|
||||||
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
||||||
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
||||||
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
|
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Ağ çıkışı</string>
|
<string name="prefs_net_title">Ağ veri çıkışı</string>
|
||||||
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
||||||
@@ -176,22 +176,22 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth çıkışı</string>
|
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
|
||||||
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
|
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Biçim</string>
|
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
|
||||||
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
|
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Biçim</string>
|
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Diğer</string>
|
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||||
<string name="prefs_other_switch_utc">UTC saati</string>
|
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||||
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sensors">Sensör</string>
|
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||||
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
@@ -205,7 +205,8 @@
|
|||||||
\n• Dave Moten (predict4java)
|
\n• Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
|
\n• BG7NTA (CW decoding feature)</string>
|
||||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -13,16 +13,12 @@
|
|||||||
<string name="nav_prefs">Налаштування</string>
|
<string name="nav_prefs">Налаштування</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Режими: %s</string>
|
<string name="sat_type_hint">Тип: %s</string>
|
||||||
<string name="sat_type_title">Виберіть режими</string>
|
<string name="sat_type_title">Виберіть тип супутника</string>
|
||||||
<string name="sat_search_hint">Id - Назва</string>
|
<string name="sat_search_hint">Id - Назва</string>
|
||||||
<string name="sat_search_clear">Очистити</string>
|
<string name="sat_search_clear">Очистити</string>
|
||||||
<string name="sat_clear_all">Очистити всі</string>
|
<string name="sat_clear_all">Очистити всі</string>
|
||||||
<string name="sat_select_all">Вибрати всі</string>
|
<string name="sat_select_all">Вибрати всі</string>
|
||||||
<string name="sat_group_selected">Обрані</string>
|
|
||||||
<string name="sat_group_available">Доступні</string>
|
|
||||||
<string name="sat_group_selected_count">Обрані (%1$d)</string>
|
|
||||||
<string name="sat_group_available_count">Доступні (%1$d)</string>
|
|
||||||
<string name="sat_empty_list_message">
|
<string name="sat_empty_list_message">
|
||||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||||
<string name="sat_warning_title">Увага\!</string>
|
<string name="sat_warning_title">Увага\!</string>
|
||||||
@@ -30,7 +26,7 @@
|
|||||||
У цьому додатку перелічено понад 9000 супутників.
|
У цьому додатку перелічено понад 9000 супутників.
|
||||||
Немає сенсу відстежувати їх усі одночасно.
|
Немає сенсу відстежувати їх усі одночасно.
|
||||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||||
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||||
@@ -39,7 +35,7 @@
|
|||||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||||
<string name="pass_modes_title">Виберіть режими</string>
|
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
||||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||||
<string name="pass_altitude">Висота: %.0f км</string>
|
<string name="pass_altitude">Висота: %.0f км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -100,11 +96,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||||
<string name="prefs_station_title">Позиція станції</string>
|
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
||||||
<string name="prefs_station_lat_text">Широта станції</string>
|
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
||||||
<string name="prefs_station_lon_text">Довгота станції</string>
|
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
||||||
<string name="prefs_locator_title">Локатор QTH</string>
|
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
||||||
<string name="prefs_locator_text">Позиція за локатором</string>
|
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Супутникові дані</string>
|
<string name="prefs_data_title">Супутникові дані</string>
|
||||||
<string name="prefs_data_entries">Супутників: %s</string>
|
<string name="prefs_data_entries">Супутників: %s</string>
|
||||||
@@ -114,52 +110,46 @@
|
|||||||
<string name="prefs_data_clear">Очистити</string>
|
<string name="prefs_data_clear">Очистити</string>
|
||||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
||||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||||
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Власні джерела</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||||
|
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Вивід даних</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Мережа</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
|
|
||||||
<string name="nav_radiocontrol">Керування радіо</string>
|
<string name="prefs_net_title">Вивід мережевих даних</string>
|
||||||
<string name="rc_settings_title">CAT керування</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="rc_radio_model">Модель радіо</string>
|
|
||||||
<string name="rc_tx_device_hint">BT адреса TX</string>
|
|
||||||
<string name="rc_rx_device_hint">BT адреса RX</string>
|
|
||||||
<string name="rc_tx_name_hint">Назва радіо TX</string>
|
|
||||||
<string name="rc_rx_name_hint">Назва радіо RX</string>
|
|
||||||
<string name="rc_enable_switch">Увімкнути CAT</string>
|
|
||||||
|
|
||||||
<string name="prefs_net_title">Мережевий вивід</string>
|
|
||||||
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
|
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||||
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вивід Bluetooth</string>
|
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
|
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
|
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
|
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
|
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Інше</string>
|
<string name="prefs_other_title">Інші налаштування</string>
|
||||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||||
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Сенсори</string>
|
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||||
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
|||||||
@@ -9,23 +9,20 @@
|
|||||||
<string name="nav_sat">卫星</string>
|
<string name="nav_sat">卫星</string>
|
||||||
<string name="nav_pass">过境</string>
|
<string name="nav_pass">过境</string>
|
||||||
<string name="nav_radar">雷达</string>
|
<string name="nav_radar">雷达</string>
|
||||||
|
<string name="nav_mutual">匹配</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">模式:%s</string>
|
<string name="sat_type_hint">类型:%s</string>
|
||||||
<string name="sat_type_title">选择模式</string>
|
<string name="sat_type_title">选择卫星类型</string>
|
||||||
<string name="sat_search_hint">Id - 名称</string>
|
<string name="sat_search_hint">Id - 名称</string>
|
||||||
<string name="sat_search_clear">清空</string>
|
<string name="sat_search_clear">清空</string>
|
||||||
<string name="sat_clear_all">全部清空</string>
|
<string name="sat_clear_all">全部清空</string>
|
||||||
<string name="sat_select_all">全选</string>
|
<string name="sat_select_all">全选</string>
|
||||||
<string name="sat_group_selected">已选择</string>
|
|
||||||
<string name="sat_group_available">可用</string>
|
|
||||||
<string name="sat_group_selected_count">已选择(%1$d)</string>
|
|
||||||
<string name="sat_group_available_count">可用(%1$d)</string>
|
|
||||||
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||||
<string name="sat_warning_title">警告\!</string>
|
<string name="sat_warning_title">警告\!</string>
|
||||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
|
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">筛选过境</string>
|
<string name="pass_filter_title">筛选过境</string>
|
||||||
@@ -34,7 +31,7 @@
|
|||||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||||
<string name="pass_modes_title">选择模式</string>
|
<string name="pass_modes_title">选择调制类型</string>
|
||||||
<string name="pass_elevation">仰角:%.1f°</string>
|
<string name="pass_elevation">仰角:%.1f°</string>
|
||||||
<string name="pass_altitude">高度:%d km</string>
|
<string name="pass_altitude">高度:%d km</string>
|
||||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||||
@@ -42,6 +39,41 @@
|
|||||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
|
|
||||||
|
<!-- AMSAT Status screen -->
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||||
|
<string name="amsat_refresh">刷新</string>
|
||||||
|
<string name="amsat_retry">重试</string>
|
||||||
|
<string name="amsat_updated">更新于:</string>
|
||||||
|
<string name="amsat_active">活跃</string>
|
||||||
|
<string name="amsat_tlm">遥测/信标</string>
|
||||||
|
<string name="amsat_not_heard">未听到</string>
|
||||||
|
<string name="amsat_conflict">冲突</string>
|
||||||
|
<string name="amsat_name">名称</string>
|
||||||
|
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||||
|
<string name="amsat_no_reports">此时间段无报告</string>
|
||||||
|
<string name="amsat_close">关闭</string>
|
||||||
|
<string name="amsat_upload">上传</string>
|
||||||
|
<string name="amsat_upload_back">返回</string>
|
||||||
|
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_status">状态</string>
|
||||||
|
<string name="amsat_upload_callsign">呼号</string>
|
||||||
|
<string name="amsat_upload_grid">网格</string>
|
||||||
|
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||||
|
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">确认上传</string>
|
||||||
|
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">未填写</string>
|
||||||
|
<string name="amsat_upload_success">报告已上传</string>
|
||||||
|
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||||
|
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||||
|
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||||
|
<string name="amsat_upload_active">活跃</string>
|
||||||
|
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||||
|
<string name="amsat_upload_not_heard">未听到</string>
|
||||||
|
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">后退</string>
|
<string name="radar_back">后退</string>
|
||||||
<string name="radar_notify">通知</string>
|
<string name="radar_notify">通知</string>
|
||||||
@@ -60,9 +92,12 @@
|
|||||||
<string name="radar_string_no">否</string>
|
<string name="radar_string_no">否</string>
|
||||||
<string name="radar_string_yes">是</string>
|
<string name="radar_string_yes">是</string>
|
||||||
<string name="radar_visible">日照中</string>
|
<string name="radar_visible">日照中</string>
|
||||||
|
|
||||||
|
<string name="radar_doppler_calc">多普勒频率计算器</string>
|
||||||
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
@@ -95,11 +130,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>
|
||||||
@@ -109,37 +144,33 @@
|
|||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_success">更新成功</string>
|
<string name="prefs_data_update_success">更新成功</string>
|
||||||
|
|
||||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">自定义源</string>
|
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
|
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label">卫星数据</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label">收发器数据</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">数据输出</string>
|
<string name="prefs_data_output_title">数据输出</string>
|
||||||
<string name="prefs_net_output">网络</string>
|
<string name="prefs_net_output">网络</string>
|
||||||
<string name="prefs_bt_output">蓝牙</string>
|
<string name="prefs_bt_output">蓝牙</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="nav_radiocontrol">电台控制</string>
|
<string name="prefs_net_title">网络数据输出</string>
|
||||||
<string name="rc_settings_title">CAT 控制</string>
|
|
||||||
<string name="rc_radio_model">电台型号</string>
|
|
||||||
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
|
|
||||||
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
|
|
||||||
<string name="rc_tx_name_hint">TX 电台名称</string>
|
|
||||||
<string name="rc_rx_name_hint">RX 电台名称</string>
|
|
||||||
<string name="rc_enable_switch">启用 CAT</string>
|
|
||||||
|
|
||||||
<string name="prefs_net_title">网络输出</string>
|
|
||||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</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_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">蓝牙输出</string>
|
<string name="prefs_bt_title">蓝牙数据输出</string>
|
||||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
|
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">数据格式</string>
|
<string name="prefs_bt_rotator_output_hint">数据格式</string>
|
||||||
@@ -149,14 +180,22 @@
|
|||||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">其他</string>
|
<!-- Radio Control -->
|
||||||
<string name="prefs_other_switch_utc">UTC 时间</string>
|
<string name="nav_radiocontrol">电台控制</string>
|
||||||
<string name="prefs_other_switch_update">自动更新数据</string>
|
<string name="rc_settings_title">CAT 电台控制</string>
|
||||||
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
<string name="rc_radio_model">电台型号</string>
|
||||||
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
|
||||||
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
|
||||||
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
|
<string name="rc_tx_name_hint">发射电台名称</string>
|
||||||
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
|
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||||
|
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||||
|
|
||||||
|
<string name="prefs_other_title">其他设置</string>
|
||||||
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
|
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||||
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">仰角高亮</string>
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
@@ -170,7 +209,41 @@
|
|||||||
\n• Dave Moten (predict4java)
|
\n• Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
|
\n• xdsopl 和 Robot36 贡献者!
|
||||||
|
\n• BG7NTA(CW解码功能)</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
|
<!-- 匹配页 Mutual match page -->
|
||||||
|
<string name="mutual_title">过境匹配</string>
|
||||||
|
<string name="mutual_station_you">你的位置</string>
|
||||||
|
<string name="mutual_station_opposite">友台位置</string>
|
||||||
|
<string name="mutual_label_you">你</string>
|
||||||
|
<string name="mutual_label_opposite">友台</string>
|
||||||
|
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
|
||||||
|
<string name="mutual_status_error">请修正提示后重新查询</string>
|
||||||
|
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
|
||||||
|
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
|
||||||
|
<string name="mutual_status_idle">输入双方位置后点击查询</string>
|
||||||
|
<string name="mutual_chip_calculating">计算中</string>
|
||||||
|
<string name="mutual_chip_error">有错误</string>
|
||||||
|
<string name="mutual_chip_matches">%d 个匹配</string>
|
||||||
|
<string name="mutual_chip_none">无匹配</string>
|
||||||
|
<string name="mutual_chip_idle">待查询</string>
|
||||||
|
<string name="mutual_use_current">填入当前位置</string>
|
||||||
|
<string name="mutual_lat">纬度</string>
|
||||||
|
<string name="mutual_lon">经度</string>
|
||||||
|
<string name="mutual_grid">网格</string>
|
||||||
|
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||||
|
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||||
|
<string name="mutual_time_range">时间范围</string>
|
||||||
|
<string name="mutual_calculating">计算中…</string>
|
||||||
|
<string name="mutual_query">查询匹配</string>
|
||||||
|
<string name="mutual_elevation_you">你:%d°</string>
|
||||||
|
<string name="mutual_elevation_opposite">友台:%d°</string>
|
||||||
|
<string name="mutual_both_tracks">双方轨迹</string>
|
||||||
|
<string name="mutual_open_radar">查看雷达</string>
|
||||||
|
<string name="mutual_station_a">站点A</string>
|
||||||
|
<string name="mutual_station_b">站点B</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -10,28 +10,24 @@
|
|||||||
<string name="nav_sat">Satellites</string>
|
<string name="nav_sat">Satellites</string>
|
||||||
<string name="nav_pass">Passes</string>
|
<string name="nav_pass">Passes</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_mutual">Match</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
<string name="nav_status" translatable="false">AMSAT</string>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Modes: %s</string>
|
<string name="sat_type_hint">Type: %s</string>
|
||||||
<string name="sat_type_title">Select modes</string>
|
<string name="sat_type_title">Select satellite type</string>
|
||||||
<string name="sat_search_hint">Id - Name</string>
|
<string name="sat_search_hint">Id - Name</string>
|
||||||
<string name="sat_search_clear">Clear</string>
|
<string name="sat_search_clear">Clear</string>
|
||||||
<string name="sat_clear_all">Clear all</string>
|
<string name="sat_clear_all">Clear all</string>
|
||||||
<string name="sat_select_all">Select all</string>
|
<string name="sat_select_all">Select all</string>
|
||||||
<string name="sat_group_selected">Selected</string>
|
|
||||||
<string name="sat_group_available">Available</string>
|
|
||||||
<string name="sat_group_selected_count">Selected (%1$d)</string>
|
|
||||||
<string name="sat_group_available_count">Available (%1$d)</string>
|
|
||||||
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
||||||
<string name="sat_warning_title">Warning\!</string>
|
<string name="sat_warning_title">Warning\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
There are over 9000 satellites listed in this app.
|
There are over 9000 satellites listed in this app.
|
||||||
It makes no sense to track them all at the same time.
|
It makes no sense to track them all at the same time.
|
||||||
\n\nAlways try to narrow down the list to only the ones
|
\n\nAlways try to narrow down the list to only the ones
|
||||||
you\'re interested in via search and the modes selector.</string>
|
you\'re interested in via search and types selector.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filter passes</string>
|
<string name="pass_filter_title">Filter passes</string>
|
||||||
@@ -41,7 +37,7 @@
|
|||||||
<string name="pass_filter_invert_time">Invert AOS window</string>
|
<string name="pass_filter_invert_time">Invert AOS window</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Select modes</string>
|
<string name="pass_modes_title">Select modulation type</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Elevation: %.1f°</string>
|
<string name="pass_elevation">Elevation: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
@@ -56,13 +52,8 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
\n* Tweaked Radar track on reposition by atsunatsu (#235)
|
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
\n* Tweaked linear transponder calc by atsunatsu (#236)
|
|
||||||
\n* Added saving per-sat doppler offset by atsunatsu (#237)
|
|
||||||
\n* Added custom freq offset setting to network reporting
|
|
||||||
\n* Added ability to customize data sources via import
|
|
||||||
\n* Fixed star chart in README by PingouinFerreux (#240)
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- AMSAT Status screen -->
|
<!-- AMSAT Status screen -->
|
||||||
@@ -79,6 +70,26 @@
|
|||||||
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||||
<string name="amsat_no_reports">No reports for this period</string>
|
<string name="amsat_no_reports">No reports for this period</string>
|
||||||
<string name="amsat_close">Close</string>
|
<string name="amsat_close">Close</string>
|
||||||
|
<string name="amsat_upload">Upload</string>
|
||||||
|
<string name="amsat_upload_back">Back</string>
|
||||||
|
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||||
|
<string name="amsat_upload_status">Status</string>
|
||||||
|
<string name="amsat_upload_callsign">Callsign</string>
|
||||||
|
<string name="amsat_upload_grid">Grid</string>
|
||||||
|
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||||
|
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">Upload</string>
|
||||||
|
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||||
|
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">Not provided</string>
|
||||||
|
<string name="amsat_upload_success">Report uploaded</string>
|
||||||
|
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||||
|
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||||
|
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
|
||||||
|
<string name="amsat_upload_active">Active</string>
|
||||||
|
<string name="amsat_upload_tlm">TLM/Beacon</string>
|
||||||
|
<string name="amsat_upload_not_heard">Not Heard</string>
|
||||||
|
<string name="amsat_upload_crew_active">Crew Active</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
@@ -105,9 +116,11 @@
|
|||||||
<string name="radar_string_no">No</string>
|
<string name="radar_string_no">No</string>
|
||||||
<string name="radar_string_yes">Yes</string>
|
<string name="radar_string_yes">Yes</string>
|
||||||
<string name="radar_visible">Visible</string>
|
<string name="radar_visible">Visible</string>
|
||||||
|
<string name="radar_doppler_calc">Doppler Frequency Calculator</string>
|
||||||
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
|
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
|
||||||
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
||||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
@@ -152,11 +165,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
||||||
<string name="prefs_loc_success">Position updated successfully</string>
|
<string name="prefs_loc_success">Position updated successfully</string>
|
||||||
<string name="prefs_station_title">Station position</string>
|
<string name="prefs_station_title">Station position settings</string>
|
||||||
<string name="prefs_station_lat_text">Station latitude</string>
|
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
||||||
<string name="prefs_station_lon_text">Station longitude</string>
|
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
||||||
<string name="prefs_locator_title">QTH locator</string>
|
<string name="prefs_locator_title">QTH locator settings</string>
|
||||||
<string name="prefs_locator_text">Set position via locator</string>
|
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Satellite data</string>
|
<string name="prefs_data_title">Satellite data</string>
|
||||||
<string name="prefs_data_entries">Satellites: %s</string>
|
<string name="prefs_data_entries">Satellites: %s</string>
|
||||||
@@ -169,7 +182,12 @@
|
|||||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Data sources</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label">Transceivers data</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
@@ -178,15 +196,15 @@
|
|||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radio Control</string>
|
<string name="nav_radiocontrol">Radio Control</string>
|
||||||
<string name="rc_settings_title">CAT control</string>
|
<string name="rc_settings_title">CAT Radio Control</string>
|
||||||
<string name="rc_radio_model">Radio Model</string>
|
<string name="rc_radio_model">Radio Model</string>
|
||||||
<string name="rc_tx_device_hint">TX BT address</string>
|
<string name="rc_tx_device_hint">TX Radio BT Address</string>
|
||||||
<string name="rc_rx_device_hint">RX BT address</string>
|
<string name="rc_rx_device_hint">RX Radio BT Address</string>
|
||||||
<string name="rc_tx_name_hint">TX Radio Name</string>
|
<string name="rc_tx_name_hint">TX Radio Name</string>
|
||||||
<string name="rc_rx_name_hint">RX Radio Name</string>
|
<string name="rc_rx_name_hint">RX Radio Name</string>
|
||||||
<string name="rc_enable_switch">Enable CAT</string>
|
<string name="rc_enable_switch">Enable CAT Control</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Network output</string>
|
<string name="prefs_net_title">Network data output</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
@@ -196,24 +214,22 @@
|
|||||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth output</string>
|
<string name="prefs_bt_title">Bluetooth data output</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Device ID</string>
|
<string name="prefs_bt_rotator_device_hint">Device id</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Format</string>
|
<string name="prefs_bt_rotator_output_hint">Data format</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Device ID</string>
|
<string name="prefs_bt_frequency_device_hint">Device id</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Format</string>
|
<string name="prefs_bt_frequency_output_hint">Data format</string>
|
||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Other</string>
|
<string name="prefs_other_title">Other settings</string>
|
||||||
<string name="prefs_other_switch_utc">UTC time</string>
|
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||||
<string name="prefs_other_switch_update">Auto-update data</string>
|
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors</string>
|
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||||
<string name="prefs_other_switch_night_mode">Night filter</string>
|
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
||||||
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
|
||||||
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
<string name="prefs_highlight_low">Low < %1$d°</string>
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
@@ -229,7 +245,40 @@
|
|||||||
\n* Dr T.S. Kelso (Celestrak)
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
\n* Libre Space Foundation (SatNOGS)
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
\n* xdsopl and Robot36 contributors!
|
\n* xdsopl and Robot36 contributors!
|
||||||
|
\n* BG7NTA (CW decoding feature)
|
||||||
</string>
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
|
<!-- Mutual match page -->
|
||||||
|
<string name="mutual_title">Pass Match</string>
|
||||||
|
<string name="mutual_station_you">Your Station</string>
|
||||||
|
<string name="mutual_station_opposite">Opposite Station</string>
|
||||||
|
<string name="mutual_label_you">You</string>
|
||||||
|
<string name="mutual_label_opposite">Opposite</string>
|
||||||
|
<string name="mutual_status_calculating">Calculating mutual visibility windows…</string>
|
||||||
|
<string name="mutual_status_error">Fix the reported issues and retry</string>
|
||||||
|
<string name="mutual_status_matches">%d matches found · tap a card to expand</string>
|
||||||
|
<string name="mutual_status_none">No mutual windows found. Adjust time or min elevation and retry</string>
|
||||||
|
<string name="mutual_status_idle">Enter both stations, then tap Query</string>
|
||||||
|
<string name="mutual_chip_calculating">Calculating</string>
|
||||||
|
<string name="mutual_chip_error">Error</string>
|
||||||
|
<string name="mutual_chip_matches">%d matches</string>
|
||||||
|
<string name="mutual_chip_none">No matches</string>
|
||||||
|
<string name="mutual_chip_idle">Idle</string>
|
||||||
|
<string name="mutual_use_current">Use current</string>
|
||||||
|
<string name="mutual_lat">Lat</string>
|
||||||
|
<string name="mutual_lon">Lon</string>
|
||||||
|
<string name="mutual_grid">Grid</string>
|
||||||
|
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
|
||||||
|
<string name="mutual_min_elevation">Min elevation: %d°</string>
|
||||||
|
<string name="mutual_time_range">Time range</string>
|
||||||
|
<string name="mutual_calculating">Calculating…</string>
|
||||||
|
<string name="mutual_query">Query Match</string>
|
||||||
|
<string name="mutual_elevation_you">You: %d°</string>
|
||||||
|
<string name="mutual_elevation_opposite">Opposite: %d°</string>
|
||||||
|
<string name="mutual_both_tracks">Both tracks</string>
|
||||||
|
<string name="mutual_open_radar">Open Radar</string>
|
||||||
|
<string name="mutual_station_a">Station A</string>
|
||||||
|
<string name="mutual_station_b">Station B</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,2 @@
|
|||||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
* Tweaked Radar track on reposition by atsunatsu (#235)
|
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
* Tweaked linear transponder calc by atsunatsu (#236)
|
|
||||||
* Added saving per-sat doppler offset by atsunatsu (#237)
|
|
||||||
* Added custom freq offset setting to network reporting
|
|
||||||
* Added ability to customize data sources via import
|
|
||||||
* Fixed star chart in README by PingouinFerreux (#240)
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.mutual"
|
||||||
|
}
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draggable dual-station elevation curve chart.
|
||||||
|
* Controlled component: [progress] (0..1) and [onProgressChange] are owned by the parent,
|
||||||
|
* so the same time cursor can be shared with the radar track view.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ElevationCurveChart(
|
||||||
|
samples: List<Pair<Long, Pair<Double, Double>>>,
|
||||||
|
startTime: Long,
|
||||||
|
endTime: Long,
|
||||||
|
maxElev: Double,
|
||||||
|
progress: Float = 0.5f,
|
||||||
|
onProgressChange: (Float) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (samples.isEmpty()) return
|
||||||
|
|
||||||
|
val colorA = MaterialTheme.colorScheme.primary
|
||||||
|
val colorB = MaterialTheme.colorScheme.tertiary
|
||||||
|
val gridColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
val colorAArgb = colorA.toArgb()
|
||||||
|
val colorBArgb = colorB.toArgb()
|
||||||
|
val textColorArgb = textColor.toArgb()
|
||||||
|
val onSurfaceArgb = onSurfaceColor.toArgb()
|
||||||
|
val gridColorArgb = gridColor.toArgb()
|
||||||
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
onProgressChange((offset.x / size.width.toFloat()).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures { change, _ ->
|
||||||
|
onProgressChange((change.position.x / size.width.toFloat()).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val chartWidth = size.width
|
||||||
|
val chartHeight = size.height
|
||||||
|
val padding = 40f
|
||||||
|
val plotLeft = padding
|
||||||
|
val plotRight = chartWidth - 10f
|
||||||
|
val plotTop = 10f
|
||||||
|
val plotBottom = chartHeight - padding
|
||||||
|
val plotWidth = plotRight - plotLeft
|
||||||
|
val plotHeight = plotBottom - plotTop
|
||||||
|
|
||||||
|
if (plotWidth <= 0f || plotHeight <= 0f) return@Canvas
|
||||||
|
|
||||||
|
val minTime = startTime
|
||||||
|
val maxTime = endTime
|
||||||
|
val timeRange = (maxTime - minTime).toFloat()
|
||||||
|
if (timeRange <= 0f) return@Canvas
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
val gridSteps = 4
|
||||||
|
for (i in 0..gridSteps) {
|
||||||
|
val y = plotBottom - (plotHeight * i / gridSteps)
|
||||||
|
drawLine(gridColor, Offset(plotLeft, y), Offset(plotRight, y), strokeWidth = 1f)
|
||||||
|
val elev = (maxElev * i / gridSteps).roundToInt()
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
"$elev°", 2f, y + 4f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = textColorArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time axis
|
||||||
|
val timeSteps = 4
|
||||||
|
for (i in 0..timeSteps) {
|
||||||
|
val x = plotLeft + (plotWidth * i / timeSteps)
|
||||||
|
val t = startTime + ((endTime - startTime) * i / timeSteps)
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
timeFormat.format(Date(t)), x - 20f, chartHeight - 2f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = textColorArgb
|
||||||
|
textSize = 22f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation curves (smooth cubic Bezier)
|
||||||
|
val pathA = Path()
|
||||||
|
val pathB = Path()
|
||||||
|
val ptsA = mutableListOf<Offset>()
|
||||||
|
val ptsB = mutableListOf<Offset>()
|
||||||
|
|
||||||
|
for ((time, elev) in samples) {
|
||||||
|
val x = plotLeft + ((time - minTime).toFloat() / timeRange) * plotWidth
|
||||||
|
val yA = plotBottom - ((elev.first.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
|
||||||
|
val yB = plotBottom - ((elev.second.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
|
||||||
|
ptsA.add(Offset(x, yA))
|
||||||
|
ptsB.add(Offset(x, yB))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ptsA.size >= 2) {
|
||||||
|
pathA.moveTo(ptsA[0].x, ptsA[0].y)
|
||||||
|
pathB.moveTo(ptsB[0].x, ptsB[0].y)
|
||||||
|
for (i in 1 until ptsA.size) {
|
||||||
|
val p0a = ptsA.getOrNull(i - 2) ?: ptsA[i - 1]
|
||||||
|
val p1a = ptsA[i - 1]
|
||||||
|
val p2a = ptsA[i]
|
||||||
|
val p3a = ptsA.getOrNull(i + 1) ?: ptsA[i]
|
||||||
|
val cp1a = Offset(p1a.x + (p2a.x - p0a.x) / 6f, p1a.y + (p2a.y - p0a.y) / 6f)
|
||||||
|
val cp2a = Offset(p2a.x - (p3a.x - p1a.x) / 6f, p2a.y - (p3a.y - p1a.y) / 6f)
|
||||||
|
pathA.cubicTo(cp1a.x, cp1a.y, cp2a.x, cp2a.y, p2a.x, p2a.y)
|
||||||
|
|
||||||
|
val p0b = ptsB.getOrNull(i - 2) ?: ptsB[i - 1]
|
||||||
|
val p1b = ptsB[i - 1]
|
||||||
|
val p2b = ptsB[i]
|
||||||
|
val p3b = ptsB.getOrNull(i + 1) ?: ptsB[i]
|
||||||
|
val cp1b = Offset(p1b.x + (p2b.x - p0b.x) / 6f, p1b.y + (p2b.y - p0b.y) / 6f)
|
||||||
|
val cp2b = Offset(p2b.x - (p3b.x - p1b.x) / 6f, p2b.y - (p3b.y - p1b.y) / 6f)
|
||||||
|
pathB.cubicTo(cp1b.x, cp1b.y, cp2b.x, cp2b.y, p2b.x, p2b.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(pathA, colorA, style = Stroke(width = 2.5f))
|
||||||
|
drawPath(pathB, colorB, style = Stroke(width = 2.5f))
|
||||||
|
|
||||||
|
// Drag indicator
|
||||||
|
val dragX = plotLeft + progress * plotWidth
|
||||||
|
val dragTime = startTime + (progress * (endTime - startTime)).toLong()
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
onSurfaceColor,
|
||||||
|
Offset(dragX, plotTop), Offset(dragX, plotBottom),
|
||||||
|
strokeWidth = 2f
|
||||||
|
)
|
||||||
|
|
||||||
|
val dragElev = getElevationAtTime(samples, dragTime)
|
||||||
|
val dragElevA = (dragElev?.first?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
|
||||||
|
val dragElevB = (dragElev?.second?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
|
||||||
|
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
timeFormat.format(Date(dragTime)),
|
||||||
|
dragX - 24f, plotBottom + 16f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = onSurfaceArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
"A:${dragElevA}°",
|
||||||
|
dragX + 6f, plotTop + 16f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = colorAArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
"B:${dragElevB}°",
|
||||||
|
dragX + 6f, plotTop + 42f,
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = colorBArgb
|
||||||
|
textSize = 24f
|
||||||
|
textAlign = android.graphics.Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.mutual_station_a), color = colorA, fontSize = 12.sp)
|
||||||
|
Text(stringResource(R.string.mutual_station_b), color = colorB, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getElevationAtTime(
|
||||||
|
samples: List<Pair<Long, Pair<Double, Double>>>,
|
||||||
|
time: Long
|
||||||
|
): Pair<Double, Double>? {
|
||||||
|
if (samples.isEmpty()) return null
|
||||||
|
if (samples.size == 1) return samples[0].second
|
||||||
|
if (time <= samples[0].first) return samples[0].second
|
||||||
|
if (time >= samples.last().first) return samples.last().second
|
||||||
|
|
||||||
|
var lo = 0
|
||||||
|
var hi = samples.size - 1
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
val mid = (lo + hi) / 2
|
||||||
|
if (samples[mid].first <= time) lo = mid
|
||||||
|
else hi = mid
|
||||||
|
}
|
||||||
|
|
||||||
|
val t0 = samples[lo].first
|
||||||
|
val t1 = samples[hi].first
|
||||||
|
val frac = if (t1 > t0) (time - t0).toFloat() / (t1 - t0).toFloat() else 0f
|
||||||
|
|
||||||
|
val e0 = samples[lo].second
|
||||||
|
val e1 = samples[hi].second
|
||||||
|
return Pair(
|
||||||
|
e0.first + (e1.first - e0.first) * frac,
|
||||||
|
e0.second + (e1.second - e0.second) * frac
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mutual pass where a satellite is visible from two stations simultaneously.
|
||||||
|
*
|
||||||
|
* @param catNum Satellite catalog number
|
||||||
|
* @param name Satellite name
|
||||||
|
* @param startTime Common AOS time (millis)
|
||||||
|
* @param endTime Common LOS time (millis)
|
||||||
|
* @param maxElevationA Peak elevation for station A
|
||||||
|
* @param maxElevationB Peak elevation for station B
|
||||||
|
* @param elevationSamples List of (time, (elevationA, elevationB)) pairs
|
||||||
|
* @param trackSamples List of radar-track samples (azimuth/elevation for both stations, degrees)
|
||||||
|
*/
|
||||||
|
data class MutualPass(
|
||||||
|
val catNum: Int,
|
||||||
|
val name: String,
|
||||||
|
val startTime: Long,
|
||||||
|
val endTime: Long,
|
||||||
|
val maxElevationA: Double,
|
||||||
|
val maxElevationB: Double,
|
||||||
|
val elevationSamples: List<Pair<Long, Pair<Double, Double>>>,
|
||||||
|
val trackSamples: List<TrackSample> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One radar-track sample: satellite position as seen from both stations.
|
||||||
|
* All angles are in degrees.
|
||||||
|
*/
|
||||||
|
data class TrackSample(
|
||||||
|
val time: Long,
|
||||||
|
val azimuthA: Double,
|
||||||
|
val elevationA: Double,
|
||||||
|
val azimuthB: Double,
|
||||||
|
val elevationB: Double
|
||||||
|
)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
private const val CIRCLES = 3 // 30°, 60°, 90° rings
|
||||||
|
private const val STROKE_WIDTH = 2.5f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polar radar chart showing the satellite track as seen from both stations
|
||||||
|
* on one plot. Station A is drawn solid, station B dashed.
|
||||||
|
* Controlled: [progress] (0..1) and [onProgressChange] are owned by the parent
|
||||||
|
* so the time cursor can be shared with the elevation curve chart.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MutualRadarView(
|
||||||
|
trackSamples: List<TrackSample>,
|
||||||
|
progress: Float = 0.5f,
|
||||||
|
labelA: String = "Station A",
|
||||||
|
labelB: String = "Station B",
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (trackSamples.isEmpty()) return
|
||||||
|
|
||||||
|
val colorA = MaterialTheme.colorScheme.primary
|
||||||
|
val colorB = MaterialTheme.colorScheme.tertiary
|
||||||
|
val gridColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
|
||||||
|
// Current-time samples (both stations' position at the shared cursor)
|
||||||
|
val t0 = trackSamples.first().time
|
||||||
|
val t1 = trackSamples.last().time
|
||||||
|
val selectedTime = t0 + ((t1 - t0) * progress).toLong()
|
||||||
|
val visibleSamples = trackSamples.filter { it.time <= selectedTime }
|
||||||
|
val currentSample = visibleSamples.lastOrNull() ?: trackSamples.first()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
val radius = size.minDimension / 2f * 0.92f
|
||||||
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
|
|
||||||
|
// Grid: elevation rings + azimuth spokes + cardinal labels
|
||||||
|
drawRadarGrid(center, radius, gridColor, textColor, measurer)
|
||||||
|
|
||||||
|
// Track paths (only the above-horizon arc, split at the 0/360° azimuth wrap)
|
||||||
|
val pathA = Path()
|
||||||
|
val pathB = Path()
|
||||||
|
var firstVisibleA = true
|
||||||
|
var firstVisibleB = true
|
||||||
|
var lastAzimA: Double? = null
|
||||||
|
var lastAzimB: Double? = null
|
||||||
|
trackSamples.forEach { sample ->
|
||||||
|
if (sample.elevationA > 0f) {
|
||||||
|
val p = sph2Cart(center, sample.azimuthA, sample.elevationA, radius)
|
||||||
|
val wrapA = lastAzimA != null && kotlin.math.abs(azimuthDelta(sample.azimuthA - lastAzimA!!)) > 180.0
|
||||||
|
if (firstVisibleA || wrapA) {
|
||||||
|
pathA.moveTo(p.x, p.y)
|
||||||
|
firstVisibleA = false
|
||||||
|
} else {
|
||||||
|
pathA.lineTo(p.x, p.y)
|
||||||
|
}
|
||||||
|
lastAzimA = sample.azimuthA
|
||||||
|
}
|
||||||
|
if (sample.elevationB > 0f) {
|
||||||
|
val p = sph2Cart(center, sample.azimuthB, sample.elevationB, radius)
|
||||||
|
val wrapB = lastAzimB != null && kotlin.math.abs(azimuthDelta(sample.azimuthB - lastAzimB!!)) > 180.0
|
||||||
|
if (firstVisibleB || wrapB) {
|
||||||
|
pathB.moveTo(p.x, p.y)
|
||||||
|
firstVisibleB = false
|
||||||
|
} else {
|
||||||
|
pathB.lineTo(p.x, p.y)
|
||||||
|
}
|
||||||
|
lastAzimB = sample.azimuthB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid line for station A, dashed for station B
|
||||||
|
drawPath(pathA, colorA, style = Stroke(STROKE_WIDTH))
|
||||||
|
drawPath(
|
||||||
|
pathB, colorB,
|
||||||
|
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)))
|
||||||
|
)
|
||||||
|
|
||||||
|
// AOS / LOS markers on both tracks
|
||||||
|
val first = trackSamples.first()
|
||||||
|
val last = trackSamples.last()
|
||||||
|
val aosA = sph2Cart(center, first.azimuthA, first.elevationA, radius)
|
||||||
|
val aosB = sph2Cart(center, first.azimuthB, first.elevationB, radius)
|
||||||
|
val losA = sph2Cart(center, last.azimuthA, last.elevationA, radius)
|
||||||
|
val losB = sph2Cart(center, last.azimuthB, last.elevationB, radius)
|
||||||
|
// AOS: hollow circle, LOS: filled circle
|
||||||
|
if (first.elevationA > 0f) drawCircle(colorA, 7f, aosA, style = Stroke(2.5f))
|
||||||
|
if (first.elevationB > 0f) drawCircle(colorB, 7f, aosB, style = Stroke(2.5f))
|
||||||
|
if (last.elevationA > 0f) drawCircle(colorA, 5f, losA)
|
||||||
|
if (last.elevationB > 0f) drawCircle(colorB, 5f, losB)
|
||||||
|
|
||||||
|
// Shared time-cursor positions (both stations at selectedTime)
|
||||||
|
val cursorA = sph2Cart(center, currentSample.azimuthA, currentSample.elevationA, radius)
|
||||||
|
val cursorB = sph2Cart(center, currentSample.azimuthB, currentSample.elevationB, radius)
|
||||||
|
drawCircle(colorA, 14f, cursorA, style = Stroke(3f))
|
||||||
|
drawCircle(colorA, 6f, cursorA)
|
||||||
|
drawCircle(colorB, 14f, cursorB, style = Stroke(3f))
|
||||||
|
drawCircle(colorB, 6f, cursorB)
|
||||||
|
|
||||||
|
// Center dot
|
||||||
|
drawCircle(gridColor, 4f, center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
Text("● $labelA", color = colorA, fontSize = 12.sp)
|
||||||
|
Text("- - $labelB", color = colorB, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawRadarGrid(
|
||||||
|
center: Offset,
|
||||||
|
radius: Float,
|
||||||
|
color: Color,
|
||||||
|
textColor: Color,
|
||||||
|
measurer: TextMeasurer
|
||||||
|
) {
|
||||||
|
// Elevation rings: 30°, 60°, 90°(center) — outer edge is horizon (0°)
|
||||||
|
val step = radius / CIRCLES
|
||||||
|
for (i in 0 until CIRCLES) {
|
||||||
|
drawCircle(color, radius - step * i, center, style = Stroke(1.5f))
|
||||||
|
}
|
||||||
|
// Cardinal spokes
|
||||||
|
drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), 1.5f)
|
||||||
|
drawLine(color, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), 1.5f)
|
||||||
|
// Diagonal spokes (lighter)
|
||||||
|
val diag = radius * 0.7071f
|
||||||
|
drawLine(
|
||||||
|
color.copy(alpha = 0.5f),
|
||||||
|
Offset(center.x - diag, center.y - diag), Offset(center.x + diag, center.y + diag), 1f
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color.copy(alpha = 0.5f),
|
||||||
|
Offset(center.x - diag, center.y + diag), Offset(center.x + diag, center.y - diag), 1f
|
||||||
|
)
|
||||||
|
|
||||||
|
// Elevation ring labels: 30° on the outer ring, 60° on the middle ring, 90° at center
|
||||||
|
// (outer edge is the 0° horizon). Labels sit just above their ring.
|
||||||
|
val style = TextStyle(color = textColor, fontSize = 11.sp)
|
||||||
|
drawText(measurer, "30°", Offset(center.x + 6f, (center.y - (radius - step)) - 24f), style = style)
|
||||||
|
drawText(measurer, "60°", Offset(center.x + 6f, (center.y - (radius - 2 * step)) - 24f), style = style)
|
||||||
|
drawText(measurer, "90°", Offset(center.x + 6f, center.y - 18f), style = style)
|
||||||
|
// Cardinal labels
|
||||||
|
drawText(measurer, "N", Offset(center.x - 8f, center.y - radius - 20f), style = style)
|
||||||
|
drawText(measurer, "E", Offset(center.x + radius + 4f, center.y - 10f), style = style)
|
||||||
|
drawText(measurer, "S", Offset(center.x - 6f, center.y + radius + 2f), style = style)
|
||||||
|
drawText(measurer, "W", Offset(center.x - radius - 24f, center.y - 10f), style = style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize an azimuth delta (degrees) into the [-180, 180] range. */
|
||||||
|
private fun azimuthDelta(deltaDeg: Double): Double {
|
||||||
|
var d = deltaDeg % 360.0
|
||||||
|
if (d > 180.0) d -= 360.0
|
||||||
|
if (d < -180.0) d += 360.0
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert azimuth (deg, 0=N, clockwise) and elevation (deg) to canvas offset. */
|
||||||
|
private fun sph2Cart(center: Offset, azimDeg: Double, elevDeg: Double, r: Float): Offset {
|
||||||
|
val azimRad = azimDeg * PI / 180.0
|
||||||
|
val elevRad = elevDeg * PI / 180.0
|
||||||
|
val radius = r * (PI / 2 - elevRad) / (PI / 2)
|
||||||
|
return Offset(
|
||||||
|
x = center.x + (radius * cos(PI / 2 - azimRad)).toFloat(),
|
||||||
|
y = center.y - (radius * sin(PI / 2 - azimRad)).toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MutualScreen(
|
||||||
|
viewModel: MutualViewModel,
|
||||||
|
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
ScreenColumn(
|
||||||
|
topBar = { isVertical ->
|
||||||
|
TopBar(
|
||||||
|
isVerticalLayout = isVertical,
|
||||||
|
startAction = {},
|
||||||
|
topInfo = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mutual_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomInfo = {
|
||||||
|
Text(
|
||||||
|
text = mutualStatusText(state),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
endAction = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
if (state.isCalculating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.width(24.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MutualStatusChip(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { isVertical ->
|
||||||
|
MutualContent(
|
||||||
|
state = state,
|
||||||
|
isVertical = isVertical,
|
||||||
|
onQuery = viewModel::queryMutualPasses,
|
||||||
|
onSelectPass = viewModel::onSelectPass,
|
||||||
|
onNavigateToRadar = navigateToRadar,
|
||||||
|
onStationALat = viewModel::onStationALat,
|
||||||
|
onStationALon = viewModel::onStationALon,
|
||||||
|
onStationAGrid = viewModel::onStationAGrid,
|
||||||
|
onStationAMinElev = viewModel::onStationAMinElev,
|
||||||
|
onStationBLat = viewModel::onStationBLat,
|
||||||
|
onStationBLon = viewModel::onStationBLon,
|
||||||
|
onStationBGrid = viewModel::onStationBGrid,
|
||||||
|
onStationBMinElev = viewModel::onStationBMinElev,
|
||||||
|
onUseCurrentPosition = viewModel::onUseCurrentPosition,
|
||||||
|
onHoursAhead = viewModel::onHoursAhead,
|
||||||
|
onClearError = viewModel::clearError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MutualContent(
|
||||||
|
state: MutualUiState,
|
||||||
|
isVertical: Boolean,
|
||||||
|
onQuery: () -> Unit,
|
||||||
|
onSelectPass: (Int) -> Unit,
|
||||||
|
onNavigateToRadar: (Int, Long, MutualPassData?) -> Unit,
|
||||||
|
onStationALat: (String) -> Unit,
|
||||||
|
onStationALon: (String) -> Unit,
|
||||||
|
onStationAGrid: (String) -> Unit,
|
||||||
|
onStationAMinElev: (Double) -> Unit,
|
||||||
|
onStationBLat: (String) -> Unit,
|
||||||
|
onStationBLon: (String) -> Unit,
|
||||||
|
onStationBGrid: (String) -> Unit,
|
||||||
|
onStationBMinElev: (Double) -> Unit,
|
||||||
|
onUseCurrentPosition: () -> Unit,
|
||||||
|
onHoursAhead: (Int) -> Unit,
|
||||||
|
onClearError: () -> Unit
|
||||||
|
) {
|
||||||
|
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// Error message
|
||||||
|
val errorMsg = state.errorMessage
|
||||||
|
if (errorMsg != null) {
|
||||||
|
item {
|
||||||
|
ElevatedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.elevatedCardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorMsg,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input form
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
if (isVertical) {
|
||||||
|
StationInputCard(
|
||||||
|
title = stringResource(R.string.mutual_station_you),
|
||||||
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
|
lat = state.stationALat,
|
||||||
|
lon = state.stationALon,
|
||||||
|
grid = state.stationAGrid,
|
||||||
|
minElev = state.stationAMinElev,
|
||||||
|
latPlaceholder = "39.9042",
|
||||||
|
lonPlaceholder = "116.4074",
|
||||||
|
gridPlaceholder = "ON79uj",
|
||||||
|
onLatChange = onStationALat,
|
||||||
|
onLonChange = onStationALon,
|
||||||
|
onGridChange = onStationAGrid,
|
||||||
|
onMinElevChange = onStationAMinElev,
|
||||||
|
currentPositionAction = onUseCurrentPosition
|
||||||
|
)
|
||||||
|
StationInputCard(
|
||||||
|
title = stringResource(R.string.mutual_station_opposite),
|
||||||
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
|
lat = state.stationBLat,
|
||||||
|
lon = state.stationBLon,
|
||||||
|
grid = state.stationBGrid,
|
||||||
|
minElev = state.stationBMinElev,
|
||||||
|
latPlaceholder = "34.0522",
|
||||||
|
lonPlaceholder = "-118.2437",
|
||||||
|
gridPlaceholder = "PM01tv",
|
||||||
|
onLatChange = onStationBLat,
|
||||||
|
onLonChange = onStationBLon,
|
||||||
|
onGridChange = onStationBGrid,
|
||||||
|
onMinElevChange = onStationBMinElev
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
StationInputCard(
|
||||||
|
title = stringResource(R.string.mutual_station_you),
|
||||||
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
|
lat = state.stationALat,
|
||||||
|
lon = state.stationALon,
|
||||||
|
grid = state.stationAGrid,
|
||||||
|
minElev = state.stationAMinElev,
|
||||||
|
latPlaceholder = "39.9042",
|
||||||
|
lonPlaceholder = "116.4074",
|
||||||
|
gridPlaceholder = "ON79uj",
|
||||||
|
onLatChange = onStationALat,
|
||||||
|
onLonChange = onStationALon,
|
||||||
|
onGridChange = onStationAGrid,
|
||||||
|
onMinElevChange = onStationAMinElev,
|
||||||
|
currentPositionAction = onUseCurrentPosition,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
StationInputCard(
|
||||||
|
title = stringResource(R.string.mutual_station_opposite),
|
||||||
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
|
lat = state.stationBLat,
|
||||||
|
lon = state.stationBLon,
|
||||||
|
grid = state.stationBGrid,
|
||||||
|
minElev = state.stationBMinElev,
|
||||||
|
latPlaceholder = "34.0522",
|
||||||
|
lonPlaceholder = "-118.2437",
|
||||||
|
gridPlaceholder = "PM01tv",
|
||||||
|
onLatChange = onStationBLat,
|
||||||
|
onLonChange = onStationBLon,
|
||||||
|
onGridChange = onStationBGrid,
|
||||||
|
onMinElevChange = onStationBMinElev,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchSearchCard(
|
||||||
|
hoursAhead = state.hoursAhead,
|
||||||
|
isCalculating = state.isCalculating,
|
||||||
|
onHoursAhead = onHoursAhead,
|
||||||
|
onQuery = onQuery
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results
|
||||||
|
itemsIndexed(state.mutualPasses) { index, pass ->
|
||||||
|
val mutualData = MutualPassData(
|
||||||
|
samples = pass.elevationSamples,
|
||||||
|
trackSamples = pass.trackSamples.map {
|
||||||
|
TrackSampleData(
|
||||||
|
time = it.time,
|
||||||
|
azimuthA = it.azimuthA,
|
||||||
|
elevationA = it.elevationA,
|
||||||
|
azimuthB = it.azimuthB,
|
||||||
|
elevationB = it.elevationB
|
||||||
|
)
|
||||||
|
},
|
||||||
|
startTime = pass.startTime,
|
||||||
|
endTime = pass.endTime,
|
||||||
|
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
|
||||||
|
labelA = stringResource(R.string.mutual_label_you),
|
||||||
|
labelB = stringResource(R.string.mutual_label_opposite)
|
||||||
|
)
|
||||||
|
MutualPassCard(
|
||||||
|
pass = pass,
|
||||||
|
isExpanded = state.selectedPassIndex == index,
|
||||||
|
timeFormat = timeFormat,
|
||||||
|
minElevA = state.stationAMinElev,
|
||||||
|
minElevB = state.stationBMinElev,
|
||||||
|
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
|
||||||
|
onNavigateToRadar = { onNavigateToRadar(pass.catNum, pass.startTime, mutualData) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MutualStatusChip(state: MutualUiState) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val containerColor = when {
|
||||||
|
state.isCalculating -> colorScheme.primaryContainer
|
||||||
|
state.errorMessage != null -> colorScheme.errorContainer
|
||||||
|
state.mutualPasses.isNotEmpty() -> colorScheme.primary
|
||||||
|
state.hasSearched -> colorScheme.surfaceVariant
|
||||||
|
else -> colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
val contentColor = when {
|
||||||
|
state.isCalculating -> colorScheme.onPrimaryContainer
|
||||||
|
state.errorMessage != null -> colorScheme.onErrorContainer
|
||||||
|
state.mutualPasses.isNotEmpty() -> colorScheme.onPrimary
|
||||||
|
else -> colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = mutualStatusChipText(state),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun mutualStatusText(state: MutualUiState): String = when {
|
||||||
|
state.isCalculating -> stringResource(R.string.mutual_status_calculating)
|
||||||
|
state.errorMessage != null -> stringResource(R.string.mutual_status_error)
|
||||||
|
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_status_matches, state.mutualPasses.size)
|
||||||
|
state.hasSearched -> stringResource(R.string.mutual_status_none)
|
||||||
|
else -> stringResource(R.string.mutual_status_idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun mutualStatusChipText(state: MutualUiState): String = when {
|
||||||
|
state.isCalculating -> stringResource(R.string.mutual_chip_calculating)
|
||||||
|
state.errorMessage != null -> stringResource(R.string.mutual_chip_error)
|
||||||
|
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_chip_matches, state.mutualPasses.size)
|
||||||
|
state.hasSearched -> stringResource(R.string.mutual_chip_none)
|
||||||
|
else -> stringResource(R.string.mutual_chip_idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StationInputCard(
|
||||||
|
title: String,
|
||||||
|
titleColor: androidx.compose.ui.graphics.Color,
|
||||||
|
lat: String,
|
||||||
|
lon: String,
|
||||||
|
grid: String,
|
||||||
|
minElev: Double,
|
||||||
|
latPlaceholder: String,
|
||||||
|
lonPlaceholder: String,
|
||||||
|
gridPlaceholder: String,
|
||||||
|
onLatChange: (String) -> Unit,
|
||||||
|
onLonChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onMinElevChange: (Double) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
currentPositionAction: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = titleColor
|
||||||
|
)
|
||||||
|
if (currentPositionAction != null) {
|
||||||
|
OutlinedButton(onClick = currentPositionAction) {
|
||||||
|
Text(stringResource(R.string.mutual_use_current), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lat,
|
||||||
|
onValueChange = onLatChange,
|
||||||
|
label = { Text(stringResource(R.string.mutual_lat)) },
|
||||||
|
placeholder = { Text(latPlaceholder) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lon,
|
||||||
|
onValueChange = onLonChange,
|
||||||
|
label = { Text(stringResource(R.string.mutual_lon)) },
|
||||||
|
placeholder = { Text(lonPlaceholder) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = grid,
|
||||||
|
onValueChange = onGridChange,
|
||||||
|
label = { Text(stringResource(R.string.mutual_grid)) },
|
||||||
|
placeholder = { Text(gridPlaceholder) },
|
||||||
|
supportingText = { Text(stringResource(R.string.mutual_grid_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mutual_min_elevation, minElev.toInt()),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = minElev.toFloat(),
|
||||||
|
onValueChange = { onMinElevChange(it.toDouble()) },
|
||||||
|
valueRange = 0f..90f,
|
||||||
|
steps = 17
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MatchSearchCard(
|
||||||
|
hoursAhead: Int,
|
||||||
|
isCalculating: Boolean,
|
||||||
|
onHoursAhead: (Int) -> Unit,
|
||||||
|
onQuery: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mutual_time_range),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
listOf(6, 12, 24, 48, 72).forEach { hours ->
|
||||||
|
FilterChip(
|
||||||
|
selected = hoursAhead == hours,
|
||||||
|
onClick = { onHoursAhead(hours) },
|
||||||
|
label = { Text("${hours}h") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onQuery,
|
||||||
|
enabled = !isCalculating,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isCalculating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(18.dp)
|
||||||
|
.width(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(if (isCalculating) stringResource(R.string.mutual_calculating) else stringResource(R.string.mutual_query))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MutualPassCard(
|
||||||
|
pass: MutualPass,
|
||||||
|
isExpanded: Boolean,
|
||||||
|
timeFormat: SimpleDateFormat,
|
||||||
|
minElevA: Double,
|
||||||
|
minElevB: Double,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onNavigateToRadar: () -> Unit
|
||||||
|
) {
|
||||||
|
// Shared time cursor: both the elevation curve and the radar track view
|
||||||
|
// are controlled by this single progress value for bidirectional drag sync.
|
||||||
|
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
|
||||||
|
|
||||||
|
// Filter to only show the portion where both stations are above their minElev
|
||||||
|
val visibleSamples = remember(pass, minElevA, minElevB) {
|
||||||
|
pass.elevationSamples.filter { (_, elev) ->
|
||||||
|
elev.first >= minElevA && elev.second >= minElevB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val visibleTracks = remember(pass, minElevA, minElevB) {
|
||||||
|
pass.trackSamples.filter { it.elevationA >= minElevA && it.elevationB >= minElevB }
|
||||||
|
}
|
||||||
|
val visibleStart = visibleSamples.firstOrNull()?.first ?: pass.startTime
|
||||||
|
val visibleEnd = visibleSamples.lastOrNull()?.first ?: pass.endTime
|
||||||
|
val adjustedMaxElev = maxOf(
|
||||||
|
visibleSamples.maxOfOrNull { (_, elev) -> elev.first } ?: 10.0,
|
||||||
|
visibleSamples.maxOfOrNull { (_, elev) -> elev.second } ?: 10.0,
|
||||||
|
10.0
|
||||||
|
)
|
||||||
|
ElevatedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = pass.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically()
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
// Dual-station radar track (polar plot)
|
||||||
|
if (visibleTracks.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.mutual_both_tracks),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
MutualRadarView(
|
||||||
|
trackSamples = visibleTracks,
|
||||||
|
progress = dragProgress,
|
||||||
|
labelA = stringResource(R.string.mutual_label_you),
|
||||||
|
labelB = stringResource(R.string.mutual_label_opposite)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
if (visibleSamples.isNotEmpty()) {
|
||||||
|
ElevationCurveChart(
|
||||||
|
samples = visibleSamples,
|
||||||
|
startTime = visibleStart,
|
||||||
|
endTime = visibleEnd,
|
||||||
|
maxElev = adjustedMaxElev,
|
||||||
|
progress = dragProgress,
|
||||||
|
onProgressChange = { dragProgress = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onNavigateToRadar,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_radar),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.height(16.dp).width(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.mutual_open_radar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
data class MutualUiState(
|
||||||
|
val stationALat: String = "",
|
||||||
|
val stationALon: String = "",
|
||||||
|
val stationAGrid: String = "",
|
||||||
|
val stationAMinElev: Double = 10.0,
|
||||||
|
val stationBLat: String = "",
|
||||||
|
val stationBLon: String = "",
|
||||||
|
val stationBGrid: String = "",
|
||||||
|
val stationBMinElev: Double = 10.0,
|
||||||
|
val hoursAhead: Int = 24,
|
||||||
|
val mutualPasses: List<MutualPass> = emptyList(),
|
||||||
|
val isCalculating: Boolean = false,
|
||||||
|
val hasSearched: Boolean = false,
|
||||||
|
val selectedPassIndex: Int = -1,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class MutualViewModel(
|
||||||
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(MutualUiState())
|
||||||
|
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Pre-fill station A with the user's current station position (as grid),
|
||||||
|
// and default min elevation to the same value used by the main radar passes
|
||||||
|
val pos = settingsRepo.stationPosition.value
|
||||||
|
val grid = latLonToGrid(pos.latitude, pos.longitude)
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationAGrid = grid,
|
||||||
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
|
stationALon = "%.4f".format(pos.longitude),
|
||||||
|
stationAMinElev = 0.0,
|
||||||
|
stationBMinElev = 0.0
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationALat(value: String) {
|
||||||
|
_uiState.update { it.copy(stationALat = value) }
|
||||||
|
val lat = value.toDoubleOrNull()
|
||||||
|
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationALon(value: String) {
|
||||||
|
_uiState.update { it.copy(stationALon = value) }
|
||||||
|
val lat = _uiState.value.stationALat.toDoubleOrNull()
|
||||||
|
val lon = value.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationAGrid(value: String) {
|
||||||
|
val old = _uiState.value.stationAGrid
|
||||||
|
_uiState.update { it.copy(stationAGrid = value) }
|
||||||
|
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||||
|
val pos = gridToLatLon(value.trim().uppercase())
|
||||||
|
if (pos != null) {
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
|
stationALon = "%.4f".format(pos.longitude)
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationBLat(value: String) {
|
||||||
|
_uiState.update { it.copy(stationBLat = value) }
|
||||||
|
val lat = value.toDoubleOrNull()
|
||||||
|
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationBLon(value: String) {
|
||||||
|
_uiState.update { it.copy(stationBLon = value) }
|
||||||
|
val lat = _uiState.value.stationBLat.toDoubleOrNull()
|
||||||
|
val lon = value.toDoubleOrNull()
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStationBGrid(value: String) {
|
||||||
|
val old = _uiState.value.stationBGrid
|
||||||
|
_uiState.update { it.copy(stationBGrid = value) }
|
||||||
|
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||||
|
val pos = gridToLatLon(value.trim().uppercase())
|
||||||
|
if (pos != null) {
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationBLat = "%.4f".format(pos.latitude),
|
||||||
|
stationBLon = "%.4f".format(pos.longitude)
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onStationAMinElev(value: Double) = _uiState.update { it.copy(stationAMinElev = value) }
|
||||||
|
fun onStationBMinElev(value: Double) = _uiState.update { it.copy(stationBMinElev = value) }
|
||||||
|
fun onUseCurrentPosition() {
|
||||||
|
val pos = settingsRepo.stationPosition.value
|
||||||
|
_uiState.update { it.copy(
|
||||||
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
|
stationALon = "%.4f".format(pos.longitude),
|
||||||
|
stationAGrid = latLonToGrid(pos.latitude, pos.longitude)
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||||
|
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||||
|
|
||||||
|
fun queryMutualPasses() {
|
||||||
|
val state = _uiState.value
|
||||||
|
|
||||||
|
// Resolve positions from lat/lon or grid
|
||||||
|
var posA = resolvePosition(state.stationALat, state.stationALon, state.stationAGrid)
|
||||||
|
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
|
||||||
|
|
||||||
|
if (posA == null || posB == null) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Please enter valid coordinates or a Maidenhead grid (4/6/8 chars)") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val satellites = satelliteRepo.satellites.value
|
||||||
|
if (satellites.isEmpty()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "No satellite data. Select satellites from the list first.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isCalculating = true,
|
||||||
|
hasSearched = true,
|
||||||
|
errorMessage = null,
|
||||||
|
mutualPasses = emptyList(),
|
||||||
|
selectedPassIndex = -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val time = System.currentTimeMillis()
|
||||||
|
val minElevA = state.stationAMinElev
|
||||||
|
val minElevB = state.stationBMinElev
|
||||||
|
val hours = state.hoursAhead
|
||||||
|
|
||||||
|
val results = withContext(Dispatchers.Default) {
|
||||||
|
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorMsg = if (results.isEmpty()) {
|
||||||
|
// Debug: check if the main pass list is the culprit
|
||||||
|
val passCount = satelliteRepo.passes.value.size
|
||||||
|
val satCount = satellites.size
|
||||||
|
when {
|
||||||
|
passCount == 0 -> "Pass list is empty. Select satellites from the list first."
|
||||||
|
satCount == 0 -> "No satellites selected."
|
||||||
|
else -> "No mutual passes found (${passCount} passes, ${satCount} satellites)."
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
mutualPasses = results.sortedBy { mp -> mp.startTime },
|
||||||
|
isCalculating = false,
|
||||||
|
errorMessage = errorMsg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a position from lat/lon text or grid square text. */
|
||||||
|
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||||
|
val trimmed = gridText.trim().uppercase()
|
||||||
|
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||||
|
return gridToLatLon(trimmed)
|
||||||
|
}
|
||||||
|
val lat = latText.toDoubleOrNull()
|
||||||
|
val lon = lonText.toDoubleOrNull()
|
||||||
|
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert lat/lon to Maidenhead grid (6-char). */
|
||||||
|
private fun latLonToGrid(lat: Double, lon: Double): String {
|
||||||
|
var adjLon = (lon + 180.0) % 360.0
|
||||||
|
var adjLat = (lat + 90.0) % 180.0
|
||||||
|
|
||||||
|
val fieldLon = (adjLon / 20.0).toInt()
|
||||||
|
val fieldLat = (adjLat / 10.0).toInt()
|
||||||
|
adjLon -= fieldLon * 20.0
|
||||||
|
adjLat -= fieldLat * 10.0
|
||||||
|
|
||||||
|
val squareLon = (adjLon / 2.0).toInt()
|
||||||
|
val squareLat = (adjLat / 1.0).toInt()
|
||||||
|
adjLon -= squareLon * 2.0
|
||||||
|
adjLat -= squareLat * 1.0
|
||||||
|
|
||||||
|
val subLon = (adjLon * 60.0 / 5.0).toInt()
|
||||||
|
val subLat = (adjLat * 60.0 / 2.5).toInt()
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
append('A' + fieldLon)
|
||||||
|
append('A' + fieldLat)
|
||||||
|
append('0' + squareLon)
|
||||||
|
append('0' + squareLat)
|
||||||
|
append('A' + subLon)
|
||||||
|
append('A' + subLat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
|
||||||
|
private fun gridToLatLon(grid: String): GeoPos? {
|
||||||
|
val g = grid.uppercase()
|
||||||
|
if (g.length < 4) return null
|
||||||
|
val lonField = (g[0] - 'A').toDouble() * 20.0
|
||||||
|
val latField = (g[1] - 'A').toDouble() * 10.0
|
||||||
|
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
|
||||||
|
|
||||||
|
val lonSquare = (g[2] - '0').toDouble() * 2.0
|
||||||
|
val latSquare = (g[3] - '0').toDouble() * 1.0
|
||||||
|
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
|
||||||
|
|
||||||
|
var lon = lonField + lonSquare
|
||||||
|
var lat = latField + latSquare
|
||||||
|
|
||||||
|
if (g.length >= 6) {
|
||||||
|
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
|
||||||
|
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
|
||||||
|
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
|
||||||
|
lon += lonSub
|
||||||
|
lat += latSub
|
||||||
|
|
||||||
|
if (g.length >= 8) {
|
||||||
|
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
|
||||||
|
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
|
||||||
|
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
|
||||||
|
lon += lonExt + 15.0 / 3600.0
|
||||||
|
lat += latExt + 7.5 / 3600.0
|
||||||
|
} else {
|
||||||
|
lon += 2.5 / 60.0
|
||||||
|
lat += 1.25 / 60.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lon += 1.0
|
||||||
|
lat += 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
lon = lon - 180.0
|
||||||
|
lat = lat - 90.0
|
||||||
|
return GeoPos(lat, lon)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findMutualPasses(
|
||||||
|
satellites: List<OrbitalObject>,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
time: Long, hours: Int
|
||||||
|
): List<MutualPass> {
|
||||||
|
val endTime = time + hours * 60L * 60L * 1000L
|
||||||
|
val sampleInterval = 5_000L
|
||||||
|
|
||||||
|
// Use the main page's pass list for AOS/LOS times, then sample the curves
|
||||||
|
// using the actual positions. The passes list is already computed by getLeoPass
|
||||||
|
// and its AOS/LOS times match the Passes page exactly.
|
||||||
|
val existingPasses = satelliteRepo.passes.value
|
||||||
|
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
|
||||||
|
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||||
|
if (results.isNotEmpty()) return results
|
||||||
|
|
||||||
|
// Fallback: try the independent search.
|
||||||
|
return findMutualPassesFallback(satellites, posA, posB,
|
||||||
|
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reuse the main page's pass list. Uses AOS/LOS times directly (no refineEdge) */
|
||||||
|
private fun findMutualPassesFromList(
|
||||||
|
existingPasses: List<OrbitalPass>,
|
||||||
|
satellites: List<OrbitalObject>,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
time: Long, endTime: Long, sampleInterval: Long
|
||||||
|
): List<MutualPass> {
|
||||||
|
val results = mutableListOf<MutualPass>()
|
||||||
|
for (pass in existingPasses) {
|
||||||
|
if (pass.losTime <= time || pass.aosTime >= endTime) continue
|
||||||
|
if (pass.isDeepSpace) continue
|
||||||
|
if (pass.orbitalObject.data.meanmo < 1e-8) continue
|
||||||
|
|
||||||
|
val sat = pass.orbitalObject
|
||||||
|
// Refine the AOS/LOS to the actual posA/posB (0° horizon).
|
||||||
|
// This ensures the elevation curve starts from ~0°.
|
||||||
|
val refinedAos = refineEdge(sat, posA, posB, pass.aosTime, 1_000L, goingUp = true)
|
||||||
|
val refinedLos = refineEdge(sat, posA, posB, pass.losTime, 1_000L, goingUp = false)
|
||||||
|
if (refinedLos <= refinedAos) continue
|
||||||
|
|
||||||
|
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
|
||||||
|
val tracks = mutableListOf<TrackSample>()
|
||||||
|
var maxElevA = 0.0
|
||||||
|
var maxElevB = 0.0
|
||||||
|
|
||||||
|
var tSample = refinedAos
|
||||||
|
while (tSample <= refinedLos) {
|
||||||
|
// Use getElevation (same function used by getLeoPass) for elevation,
|
||||||
|
// and getFullPosition only for azimuth.
|
||||||
|
val elevA = sat.getElevation(posA, tSample) * 180.0 / PI
|
||||||
|
val elevB = sat.getElevation(posB, tSample) * 180.0 / PI
|
||||||
|
val fullA = sat.getFullPosition(posA, tSample)
|
||||||
|
val fullB = sat.getFullPosition(posB, tSample)
|
||||||
|
if (elevA > maxElevA) maxElevA = elevA
|
||||||
|
if (elevB > maxElevB) maxElevB = elevB
|
||||||
|
samples.add(tSample to (elevA to elevB))
|
||||||
|
tracks.add(
|
||||||
|
TrackSample(
|
||||||
|
time = tSample,
|
||||||
|
azimuthA = fullA.azimuth * 180.0 / PI,
|
||||||
|
elevationA = elevA,
|
||||||
|
azimuthB = fullB.azimuth * 180.0 / PI,
|
||||||
|
elevationB = elevB
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tSample += sampleInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search uses the 0° horizon as the pass boundary, but the user sliders are
|
||||||
|
// a final visibility filter. Both stations must exceed their own threshold;
|
||||||
|
// otherwise the result would expand to an empty filtered chart.
|
||||||
|
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
|
||||||
|
results.add(
|
||||||
|
MutualPass(
|
||||||
|
catNum = pass.catNum,
|
||||||
|
name = pass.orbitalObject.data.name,
|
||||||
|
startTime = refinedAos,
|
||||||
|
endTime = refinedLos,
|
||||||
|
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||||
|
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||||
|
elevationSamples = samples,
|
||||||
|
trackSamples = tracks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback: search passes independently (same logic as original findMutualPasses). */
|
||||||
|
private fun findMutualPassesFallback(
|
||||||
|
satellites: List<OrbitalObject>,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
time: Long, endTime: Long, sampleInterval: Long
|
||||||
|
): List<MutualPass> {
|
||||||
|
val results = mutableListOf<MutualPass>()
|
||||||
|
for (sat in satellites) {
|
||||||
|
if (sat.data.meanmo < 1e-8) continue
|
||||||
|
var searchStart = time
|
||||||
|
while (true) {
|
||||||
|
val t = findNextMutualPass(sat, posA, posB, searchStart, endTime)
|
||||||
|
if (t == null) break
|
||||||
|
val (aos, los) = t
|
||||||
|
|
||||||
|
val refinedAos = refineEdge(sat, posA, posB, aos, 1_000L, true)
|
||||||
|
val refinedLos = refineEdge(sat, posA, posB, los, 1_000L, false)
|
||||||
|
if (refinedLos <= refinedAos) continue
|
||||||
|
|
||||||
|
val mutualPass = sampleMutualPass(sat, posA, posB, refinedAos, refinedLos,
|
||||||
|
minElevADeg, minElevBDeg, sampleInterval)
|
||||||
|
if (mutualPass != null) results.add(mutualPass)
|
||||||
|
searchStart = refinedLos + 120_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample elevation/azimuth data for one mutual pass window. */
|
||||||
|
private fun sampleMutualPass(
|
||||||
|
sat: OrbitalObject, posA: GeoPos, posB: GeoPos,
|
||||||
|
refinedAos: Long, refinedLos: Long,
|
||||||
|
minElevADeg: Double, minElevBDeg: Double,
|
||||||
|
sampleInterval: Long
|
||||||
|
): MutualPass? {
|
||||||
|
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
|
||||||
|
val tracks = mutableListOf<TrackSample>()
|
||||||
|
var maxElevA = 0.0
|
||||||
|
var maxElevB = 0.0
|
||||||
|
|
||||||
|
var tSample = refinedAos
|
||||||
|
while (tSample <= refinedLos) {
|
||||||
|
// Use getElevation for elevation, matching the main pass predictor; use
|
||||||
|
// getFullPosition only for azimuth needed by the radar plot.
|
||||||
|
val elevA = elevationDeg(sat, posA, tSample)
|
||||||
|
val elevB = elevationDeg(sat, posB, tSample)
|
||||||
|
val fullA = sat.getFullPosition(posA, tSample)
|
||||||
|
val fullB = sat.getFullPosition(posB, tSample)
|
||||||
|
if (elevA > maxElevA) maxElevA = elevA
|
||||||
|
if (elevB > maxElevB) maxElevB = elevB
|
||||||
|
samples.add(tSample to (elevA to elevB))
|
||||||
|
tracks.add(
|
||||||
|
TrackSample(
|
||||||
|
time = tSample,
|
||||||
|
azimuthA = fullA.azimuth * 180.0 / PI,
|
||||||
|
elevationA = elevA,
|
||||||
|
azimuthB = fullB.azimuth * 180.0 / PI,
|
||||||
|
elevationB = elevB
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tSample += sampleInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
|
||||||
|
return MutualPass(
|
||||||
|
catNum = sat.data.catnum,
|
||||||
|
name = sat.data.name,
|
||||||
|
startTime = refinedAos,
|
||||||
|
endTime = refinedLos,
|
||||||
|
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||||
|
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||||
|
elevationSamples = samples,
|
||||||
|
trackSamples = tracks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refine the AOS (goingUp=true) or LOS (goingUp=false) to ~1s precision at the 0° horizon. */
|
||||||
|
private fun refineEdge(
|
||||||
|
sat: OrbitalObject, posA: GeoPos, posB: GeoPos,
|
||||||
|
approxTime: Long, step: Long, goingUp: Boolean
|
||||||
|
): Long {
|
||||||
|
if (goingUp) {
|
||||||
|
// AOS: walk backward from approxTime to find the last sample where either is below
|
||||||
|
// the horizon, then AOS is the next step after that.
|
||||||
|
var t = approxTime
|
||||||
|
while (t > approxTime - 70_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, t)
|
||||||
|
val eB = elevationDeg(sat, posB, t)
|
||||||
|
if (eA > 0.0 && eB > 0.0) {
|
||||||
|
t -= step
|
||||||
|
} else {
|
||||||
|
return t + step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return approxTime - 70_000L + step
|
||||||
|
} else {
|
||||||
|
// LOS: walk forward from approxTime to find the first sample where either drops
|
||||||
|
// below the horizon, then LOS is the step before that.
|
||||||
|
var t = approxTime
|
||||||
|
while (t < approxTime + 70_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, t)
|
||||||
|
val eB = elevationDeg(sat, posB, t)
|
||||||
|
if (eA > 0.0 && eB > 0.0) {
|
||||||
|
t += step
|
||||||
|
} else {
|
||||||
|
return t - step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return approxTime + 70_000L - step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun elevationDeg(sat: OrbitalObject, pos: GeoPos, time: Long): Double {
|
||||||
|
return sat.getElevation(pos, time) * 180.0 / PI
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findNextMutualPass(
|
||||||
|
sat: OrbitalObject,
|
||||||
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
startTime: Long, endTime: Long
|
||||||
|
): Pair<Long, Long>? {
|
||||||
|
var t = startTime
|
||||||
|
val step = 60_000L
|
||||||
|
|
||||||
|
// Skip an in-progress mutual window at the search start (same as getLeoPass):
|
||||||
|
// walk forward until either station drops below the horizon, then keep searching.
|
||||||
|
if (elevationDeg(sat, posA, t) > 0.0 && elevationDeg(sat, posB, t) > 0.0) {
|
||||||
|
while (t < endTime) {
|
||||||
|
if (elevationDeg(sat, posA, t) <= 0.0 || elevationDeg(sat, posB, t) <= 0.0) break
|
||||||
|
t += step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (t < endTime) {
|
||||||
|
val elevA = elevationDeg(sat, posA, t)
|
||||||
|
val elevB = elevationDeg(sat, posB, t)
|
||||||
|
|
||||||
|
if (elevA > 0.0 && elevB > 0.0) {
|
||||||
|
var aos = t
|
||||||
|
var rew = t
|
||||||
|
while (rew > startTime - 600_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, rew)
|
||||||
|
val eB = elevationDeg(sat, posB, rew)
|
||||||
|
if (eA <= 0.0 || eB <= 0.0) {
|
||||||
|
aos = rew + step
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rew -= step
|
||||||
|
}
|
||||||
|
|
||||||
|
var los = t
|
||||||
|
var fwd = t
|
||||||
|
while (fwd < endTime + 600_000L) {
|
||||||
|
val eA = elevationDeg(sat, posA, fwd)
|
||||||
|
val eB = elevationDeg(sat, posB, fwd)
|
||||||
|
if (eA <= 0.0 || eB <= 0.0) {
|
||||||
|
los = fwd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fwd += step
|
||||||
|
}
|
||||||
|
|
||||||
|
if (los > aos) return Pair(aos, los)
|
||||||
|
}
|
||||||
|
t += step
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() = _uiState.update { it.copy(errorMessage = null) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(container: IMainContainer) = object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
MutualViewModel(
|
||||||
|
satelliteRepo = container.satelliteRepo,
|
||||||
|
settingsRepo = container.settingsRepo
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,13 +58,20 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val allModes = listOf(
|
||||||
|
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
||||||
|
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
|
||||||
|
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
|
||||||
|
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
|
||||||
|
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
|
||||||
|
)
|
||||||
|
|
||||||
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
||||||
private const val dayMinutes = 24 * 60
|
private const val dayMinutes = 24 * 60
|
||||||
private const val minuteStep = 15
|
private const val minuteStep = 15
|
||||||
@@ -140,7 +147,7 @@ internal fun PassesFilterDialog(
|
|||||||
)
|
)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_elev),
|
title = stringResource(R.string.pass_filter_elev),
|
||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
@@ -365,7 +372,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
|||||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
||||||
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(240.dp),
|
columns = GridCells.Adaptive(240.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -374,7 +381,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(Sources.satelliteModes) { index, item ->
|
itemsIndexed(allModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ 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
|
||||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
@@ -84,19 +84,23 @@ import java.util.Locale
|
|||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
fun PassesDestination(
|
||||||
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
|
navigateToMap: () -> Unit
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
PassesScreen(uiState, viewModel::onAction, navigateToRadar, navigateToMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesScreen(
|
private fun PassesScreen(
|
||||||
uiState: PassesState,
|
uiState: PassesState,
|
||||||
onAction: (PassesAction) -> Unit,
|
onAction: (PassesAction) -> Unit,
|
||||||
navigateToRadar: (Int, Long) -> Unit
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
|
navigateToMap: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isPassesDialogShown) {
|
if (uiState.isPassesDialogShown) {
|
||||||
PassesFilterDialog(
|
PassesFilterDialog(
|
||||||
@@ -135,14 +139,26 @@ private fun PassesScreen(
|
|||||||
}
|
}
|
||||||
if (uiState.shouldSeeWhatsNew) {
|
if (uiState.shouldSeeWhatsNew) {
|
||||||
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
||||||
WhatsNewDialog(onDismiss = dismiss)
|
SharedDialog(
|
||||||
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
|
onDismissRequest = dismiss,
|
||||||
|
onAccept = dismiss,
|
||||||
|
titleFontSize = 18
|
||||||
|
) { padding ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pass_whatsnew_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVerticalLayout,
|
isVerticalLayout = isVerticalLayout,
|
||||||
startAction = {
|
startAction = {
|
||||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||||
@@ -151,7 +167,7 @@ private fun PassesScreen(
|
|||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -278,11 +294,6 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun displayLocale(): Locale {
|
|
||||||
val locale = Locale.getDefault()
|
|
||||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeepSpacePassPreview() {
|
private fun DeepSpacePassPreview() {
|
||||||
@@ -309,13 +320,12 @@ private fun PassItem(
|
|||||||
isVerticalLayout: Boolean = true,
|
isVerticalLayout: Boolean = true,
|
||||||
isUtc: Boolean = false
|
isUtc: Boolean = false
|
||||||
) {
|
) {
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
|
||||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
||||||
val timeZone = remember(isUtc) {
|
val timeZone = remember(isUtc) {
|
||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", Locale.getDefault()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||||
@@ -335,10 +345,6 @@ private fun PassItem(
|
|||||||
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
|
||||||
text = "$passSatId - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = pass.name,
|
text = pass.name,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -348,7 +354,7 @@ private fun PassItem(
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
val elevColor = elevationColor(pass.maxElevation)
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
+95
-72
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
@@ -34,17 +35,14 @@ 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
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class PassesViewModel(
|
class PassesViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -64,7 +62,7 @@ class PassesViewModel(
|
|||||||
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
||||||
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
||||||
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
||||||
modes = settingsRepo.selectedSatModes.value,
|
modes = settingsRepo.passesSettings.value.selectedModes,
|
||||||
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -90,42 +88,32 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Main tick loop: reacts to new passes, then ticks every second
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.selectedSatModes.collectLatest { modes ->
|
satelliteRepo.passes.collectLatest { allPasses ->
|
||||||
_uiState.update { it.copy(modes = modes) }
|
while (isActive) {
|
||||||
}
|
val timeNow = System.currentTimeMillis()
|
||||||
}
|
val isUtc = _uiState.value.isUtc
|
||||||
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
val showDeepSpace = _uiState.value.showDeepSpace
|
||||||
// computations run once per restart, progress/countdown are calculated every second.
|
val filtered = allPasses
|
||||||
viewModelScope.launch {
|
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||||
combine(
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
satelliteRepo.passes,
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
val sunTimes = computeSunTimes(processed, isUtc)
|
||||||
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
val grouped = groupPasses(processed, isUtc)
|
||||||
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
_uiState.update {
|
||||||
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
it.copy(
|
||||||
val filtered = if (showDeepSpace) allPasses
|
itemsList = processed,
|
||||||
else allPasses.filter { !it.isDeepSpace }
|
groupedPasses = grouped,
|
||||||
// Expensive: recompute sun times once per items/UTC change, not every second
|
sunTimes = sunTimes,
|
||||||
val sunTimes = computeSunTimes(filtered, isUtc)
|
nextPass = nextPass,
|
||||||
_uiState.update { it.copy(sunTimes = sunTimes) }
|
nextTime = nextTime,
|
||||||
while (isActive) {
|
isNextTimeAos = isAos
|
||||||
val timeNow = System.currentTimeMillis()
|
)
|
||||||
val processed = computePassProgress(filtered, timeNow)
|
|
||||||
val grouped = groupPasses(processed, isUtc)
|
|
||||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
itemsList = processed,
|
|
||||||
groupedPasses = grouped,
|
|
||||||
nextPass = nextPass,
|
|
||||||
nextTime = nextTime,
|
|
||||||
isNextTimeAos = isAos
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000.milliseconds)
|
|
||||||
}
|
}
|
||||||
|
delay(1000)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +129,21 @@ class PassesViewModel(
|
|||||||
aosStartMinute = action.aosStartMinute,
|
aosStartMinute = action.aosStartMinute,
|
||||||
aosEndMinute = action.aosEndMinute,
|
aosEndMinute = action.aosEndMinute,
|
||||||
invertAosTimeWindow = action.invertAosTimeWindow,
|
invertAosTimeWindow = action.invertAosTimeWindow,
|
||||||
showDeepSpace = action.showDeepSpace
|
showDeepSpace = action.showDeepSpace,
|
||||||
|
modes = _uiState.value.modes
|
||||||
|
)
|
||||||
|
is PassesAction.FilterRadios ->
|
||||||
|
applyFilter(
|
||||||
|
hoursAhead = _uiState.value.hours,
|
||||||
|
minElevation = _uiState.value.elevation,
|
||||||
|
lowElevation = _uiState.value.lowElevation,
|
||||||
|
highElevation = _uiState.value.highElevation,
|
||||||
|
aosStartMinute = _uiState.value.aosStartMinute,
|
||||||
|
aosEndMinute = _uiState.value.aosEndMinute,
|
||||||
|
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
||||||
|
showDeepSpace = _uiState.value.showDeepSpace,
|
||||||
|
modes = action.modes
|
||||||
)
|
)
|
||||||
is PassesAction.FilterRadios -> setModesFilter(action.modes)
|
|
||||||
PassesAction.RefreshPasses -> refreshPasses()
|
PassesAction.RefreshPasses -> refreshPasses()
|
||||||
PassesAction.TogglePassesDialog ->
|
PassesAction.TogglePassesDialog ->
|
||||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
@@ -154,32 +154,30 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun displayLocale(): Locale {
|
/** Returns a locale-appropriate date format for pass grouping headers. */
|
||||||
val locale = Locale.getDefault()
|
|
||||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
|
|
||||||
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
|
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
|
||||||
val locale = displayLocale()
|
val pattern = if (Locale.getDefault().language == "zh") {
|
||||||
val pattern = if (locale.language == Locale.CHINESE.language) {
|
|
||||||
"yyyy'年'M'月'd'日' EEEE"
|
"yyyy'年'M'月'd'日' EEEE"
|
||||||
} else {
|
} else {
|
||||||
"EEE, dd MMM yyyy"
|
"EEE, dd MMM yyyy"
|
||||||
}
|
}
|
||||||
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
|
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace.
|
||||||
|
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
|
||||||
|
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
|
||||||
|
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
|
||||||
|
// For white nights / polar day edge cases, we fall back to searching from the previous day.
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = dateFormat(tz)
|
val sdfDate = dateFormat(tz)
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result["DeepSpace (period >225min)"] = rise to set
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
@@ -188,7 +186,7 @@ class PassesViewModel(
|
|||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
val label = sdfDate.format(Date(pass.aosTime))
|
val label = sdfDate.format(Date(pass.aosTime))
|
||||||
if (label in result) continue
|
if (label in result) continue
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
|
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result[label] = rise to set
|
result[label] = rise to set
|
||||||
@@ -196,6 +194,43 @@ class PassesViewModel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find today's sunrise and sunset by starting from local midnight.
|
||||||
|
* Handles the white nights / polar day edge case where the sun is still above
|
||||||
|
* the -0.8333° threshold at midnight: in that case we search from the previous
|
||||||
|
* day's midnight to find a valid sunrise/sunset pair within the target day.
|
||||||
|
*/
|
||||||
|
private fun findTodaySunRiseSet(observer: GeoPos, localMidnightMillis: Long): CelestialComputer.RiseSetTimes {
|
||||||
|
val threshold = 0.8333
|
||||||
|
val sunPos = CelestialComputer.getSunPosition(observer, localMidnightMillis)
|
||||||
|
if (sunPos.elevation > -threshold) {
|
||||||
|
// White nights / polar day: the sun is too high at midnight.
|
||||||
|
// Search from earlier days so the algorithm goes through a full
|
||||||
|
// sunset → sunrise → sunset cycle, landing in the target day.
|
||||||
|
val dayEnd = localMidnightMillis + 86400000L
|
||||||
|
for (offset in 1..3) {
|
||||||
|
val result = CelestialComputer.findSunRiseSet(observer, localMidnightMillis - offset * 86400000L)
|
||||||
|
if (result.riseTimeMillis in localMidnightMillis..dayEnd &&
|
||||||
|
result.setTimeMillis in localMidnightMillis..dayEnd) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No valid pair found (midnight sun / polar day)
|
||||||
|
return CelestialComputer.RiseSetTimes(0L, 0L)
|
||||||
|
}
|
||||||
|
return CelestialComputer.findSunRiseSet(observer, localMidnightMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
|
||||||
|
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
||||||
|
timeInMillis = timeMillis
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = dateFormat(tz)
|
val sdfDate = dateFormat(tz)
|
||||||
@@ -253,7 +288,8 @@ class PassesViewModel(
|
|||||||
aosStartMinute: Int,
|
aosStartMinute: Int,
|
||||||
aosEndMinute: Int,
|
aosEndMinute: Int,
|
||||||
invertAosTimeWindow: Boolean,
|
invertAosTimeWindow: Boolean,
|
||||||
showDeepSpace: Boolean
|
showDeepSpace: Boolean,
|
||||||
|
modes: List<String>
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
settingsRepo.setPassesSettings(
|
settingsRepo.setPassesSettings(
|
||||||
PassesSettings(
|
PassesSettings(
|
||||||
@@ -262,7 +298,8 @@ class PassesViewModel(
|
|||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow
|
invertAosTimeWindow,
|
||||||
|
modes
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
||||||
@@ -275,10 +312,10 @@ class PassesViewModel(
|
|||||||
aosStartMinute = aosStartMinute,
|
aosStartMinute = aosStartMinute,
|
||||||
aosEndMinute = aosEndMinute,
|
aosEndMinute = aosEndMinute,
|
||||||
invertAosTimeWindow = invertAosTimeWindow,
|
invertAosTimeWindow = invertAosTimeWindow,
|
||||||
showDeepSpace = showDeepSpace
|
showDeepSpace = showDeepSpace,
|
||||||
|
modes = modes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val modes = settingsRepo.selectedSatModes.value
|
|
||||||
satelliteRepo.calculatePasses(
|
satelliteRepo.calculatePasses(
|
||||||
time = System.currentTimeMillis(),
|
time = System.currentTimeMillis(),
|
||||||
hoursAhead = hoursAhead,
|
hoursAhead = hoursAhead,
|
||||||
@@ -290,20 +327,6 @@ class PassesViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
|
|
||||||
settingsRepo.setSelectedSatModes(modes)
|
|
||||||
_uiState.update { it.copy(modes = modes) }
|
|
||||||
satelliteRepo.calculatePasses(
|
|
||||||
time = System.currentTimeMillis(),
|
|
||||||
hoursAhead = _uiState.value.hours,
|
|
||||||
minElevation = _uiState.value.elevation,
|
|
||||||
aosStartMinute = _uiState.value.aosStartMinute,
|
|
||||||
aosEndMinute = _uiState.value.aosEndMinute,
|
|
||||||
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
|
||||||
modes = modes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshPasses() = viewModelScope.launch {
|
private fun refreshPasses() = viewModelScope.launch {
|
||||||
val settings = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
satelliteRepo.calculatePasses(
|
satelliteRepo.calculatePasses(
|
||||||
@@ -313,7 +336,7 @@ class PassesViewModel(
|
|||||||
aosStartMinute = settings.aosStartMinute,
|
aosStartMinute = settings.aosStartMinute,
|
||||||
aosEndMinute = settings.aosEndMinute,
|
aosEndMinute = settings.aosEndMinute,
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
modes = settingsRepo.selectedSatModes.value
|
modes = settings.selectedModes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rtbishop.look4sat.feature.radar"
|
namespace = "com.rtbishop.look4sat.feature.radar"
|
||||||
}
|
}
|
||||||
@@ -60,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.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
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
|
||||||
@@ -73,6 +74,8 @@ import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
|||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.PI
|
||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
@@ -81,11 +84,35 @@ private enum class RadarPage(val title: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarDestination(navigateUp: () -> Unit) {
|
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
||||||
|
val navigateUpAndClearMutual = {
|
||||||
|
if (container.mutualPassData.value.endTime > 0L) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
}
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
val navigateToMapAndClearMutual = {
|
||||||
|
if (container.mutualPassData.value.endTime > 0L) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
}
|
||||||
|
navigateToMap()
|
||||||
|
}
|
||||||
|
LaunchedEffect(mutualData.endTime) {
|
||||||
|
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||||
|
while (true) {
|
||||||
|
val remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||||
|
if (remainingMs <= 0L) {
|
||||||
|
navigateUpAndClearMutual()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
delay(remainingMs.coerceAtMost(1000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sync actual permission state on every recomposition so it survives screen re-entry
|
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||||
val hasPermission = ContextCompat.checkSelfPermission(
|
val hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.RECORD_AUDIO
|
context, Manifest.permission.RECORD_AUDIO
|
||||||
@@ -95,8 +122,10 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
}
|
}
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
|
) { granted ->
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
|
}
|
||||||
|
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -106,12 +135,35 @@ private fun RadarScreen(
|
|||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
|
navigateToMap: () -> Unit,
|
||||||
|
mutualData: MutualPassData,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
val addToCalendar: () -> Unit = {
|
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
|
||||||
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
// at the current moment, same display mode as the local station.
|
||||||
|
val trackB = remember(mutualData.trackSamples) {
|
||||||
|
mutualData.trackSamples
|
||||||
|
.filter { it.elevationB > 0.0 }
|
||||||
|
.map {
|
||||||
|
OrbitalPos(
|
||||||
|
azimuth = it.azimuthB * PI / 180.0,
|
||||||
|
elevation = it.elevationB * PI / 180.0,
|
||||||
|
time = it.time
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
val timeNow = System.currentTimeMillis()
|
||||||
|
val trackBPosition = mutualData.trackSamples
|
||||||
|
.filter { it.time <= timeNow }
|
||||||
|
.lastOrNull()
|
||||||
|
?.let {
|
||||||
|
OrbitalPos(
|
||||||
|
azimuth = it.azimuthB * PI / 180.0,
|
||||||
|
elevation = it.elevationB * PI / 180.0,
|
||||||
|
time = it.time
|
||||||
|
)
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutPadding()
|
.layoutPadding()
|
||||||
@@ -123,7 +175,7 @@ private fun RadarScreen(
|
|||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||||
}
|
}
|
||||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||||
} else {
|
} else {
|
||||||
@@ -131,15 +183,15 @@ private fun RadarScreen(
|
|||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,8 +250,8 @@ private fun PagerCard(
|
|||||||
transceivers = uiState.transceivers.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
orbitalPos = uiState.orbitalPos,
|
orbitalPos = uiState.orbitalPos,
|
||||||
onAction = onAction,
|
calculatorOffsetKHz = uiState.calculatorOffsetKHz,
|
||||||
calculatorOffsetKHz = uiState.calculatorOffsetKHz
|
onAction = onAction
|
||||||
)
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
@@ -214,7 +266,12 @@ private fun PagerCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
private fun RadarCard(
|
||||||
|
uiState: RadarState,
|
||||||
|
trackB: List<OrbitalPos> = emptyList(),
|
||||||
|
trackBPosition: OrbitalPos? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
val satellitePos = uiState.orbitalPos
|
val satellitePos = uiState.orbitalPos
|
||||||
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||||
// Always call these composables unconditionally — conditional composable calls violate
|
// Always call these composables unconditionally — conditional composable calls violate
|
||||||
@@ -247,10 +304,11 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
|||||||
RadarViewCompose(
|
RadarViewCompose(
|
||||||
item = position,
|
item = position,
|
||||||
items = uiState.satTrack,
|
items = uiState.satTrack,
|
||||||
|
trackB = trackB.takeIf { it.isNotEmpty() },
|
||||||
|
trackBPosition = trackBPosition,
|
||||||
azimElev = uiState.orientationValues,
|
azimElev = uiState.orientationValues,
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
shouldShowSweep = uiState.shouldShowSweep,
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
shouldUseCompass = uiState.shouldUseCompass,
|
||||||
shouldFlipRadar = uiState.shouldFlipRadar,
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
sunPosition = uiState.sunPosition,
|
sunPosition = uiState.sunPosition,
|
||||||
moonPosition = uiState.moonPosition,
|
moonPosition = uiState.moonPosition,
|
||||||
|
|||||||
@@ -58,12 +58,17 @@ data class RadarState(
|
|||||||
val satTrack: List<OrbitalPos> = emptyList(),
|
val satTrack: List<OrbitalPos> = emptyList(),
|
||||||
val shouldShowSweep: Boolean = false,
|
val shouldShowSweep: Boolean = false,
|
||||||
val shouldUseCompass: Boolean = false,
|
val shouldUseCompass: Boolean = false,
|
||||||
val shouldFlipRadar: Boolean = false,
|
|
||||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
val transceivers: TransceiverSubState = TransceiverSubState(),
|
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||||
val sstv: SstvSubState = SstvSubState(),
|
val sstv: SstvSubState = SstvSubState(),
|
||||||
|
val mutualSamples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
||||||
|
val mutualStartTime: Long = 0L,
|
||||||
|
val mutualEndTime: Long = 0L,
|
||||||
|
val mutualMaxElev: Double = 10.0,
|
||||||
|
val mutualLabelA: String = "你",
|
||||||
|
val mutualLabelB: String = "友台",
|
||||||
val calculatorOffsetKHz: String = ""
|
val calculatorOffsetKHz: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.PI_2
|
import com.rtbishop.look4sat.core.domain.predict.PI_2
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|
||||||
@@ -71,10 +73,12 @@ private const val SWEEP_DURATION_MS = 8_000
|
|||||||
fun RadarViewCompose(
|
fun RadarViewCompose(
|
||||||
item: OrbitalPos,
|
item: OrbitalPos,
|
||||||
items: List<OrbitalPos>,
|
items: List<OrbitalPos>,
|
||||||
|
trackB: List<OrbitalPos>? = null,
|
||||||
|
trackBColor: Color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
trackBPosition: OrbitalPos? = null,
|
||||||
azimElev: Pair<Float, Float>,
|
azimElev: Pair<Float, Float>,
|
||||||
shouldShowSweep: Boolean,
|
shouldShowSweep: Boolean,
|
||||||
shouldUseCompass: Boolean,
|
shouldUseCompass: Boolean,
|
||||||
shouldFlipRadar: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sunPosition: CelestialComputer.SunPosition? = null,
|
sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
moonPosition: CelestialComputer.MoonPosition? = null,
|
moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
@@ -108,32 +112,53 @@ fun RadarViewCompose(
|
|||||||
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
||||||
var trackPath by remember { mutableStateOf(Path()) }
|
var trackPath by remember { mutableStateOf(Path()) }
|
||||||
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
||||||
|
// Station-B overlay track (dashed, with the same directional arrow stamp as station A)
|
||||||
|
var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
|
||||||
|
var trackBPath by remember { mutableStateOf(Path()) }
|
||||||
|
var trackBEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
||||||
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
||||||
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
||||||
|
|
||||||
Canvas(modifier = modifier.aspectRatio(1f)) {
|
Canvas(modifier = modifier.aspectRatio(1f)) {
|
||||||
val radius = size.minDimension / 2f * 0.95f
|
val radius = size.minDimension / 2f * 0.95f
|
||||||
// Rebuild track path and sweep brush when canvas size or track data changes
|
// Rebuild track path and sweep brush when canvas size or track data changes
|
||||||
if (radius != cachedRadius || items !== cachedItemsRef) {
|
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
|
||||||
trackPath = createTrackPath(items, radius)
|
trackPath = createTrackPath(items, radius)
|
||||||
trackEffect = createTrackEffect(trackPath)
|
trackEffect = createTrackEffect(trackPath)
|
||||||
|
val newTrackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
|
||||||
|
trackBPath = newTrackBPath
|
||||||
|
trackBEffect = if (!newTrackBPath.isEmpty) createTrackEffect(newTrackBPath)
|
||||||
|
else PathEffect.cornerPathEffect(0f)
|
||||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedRadius = radius
|
cachedRadius = radius
|
||||||
cachedItemsRef = items
|
cachedItemsRef = items
|
||||||
|
cachedTrackBRef = trackB
|
||||||
cachedSweepColor = primaryColor
|
cachedSweepColor = primaryColor
|
||||||
} else if (primaryColor != cachedSweepColor) {
|
} else if (primaryColor != cachedSweepColor) {
|
||||||
// Rebuild brush on theme change without waiting for a size change
|
// Rebuild brush on theme change without waiting for a size change
|
||||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedSweepColor = primaryColor
|
cachedSweepColor = primaryColor
|
||||||
}
|
}
|
||||||
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
|
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
||||||
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
|
|
||||||
rotate(rotation) {
|
|
||||||
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||||
drawRadar(radius, radarColor)
|
drawRadar(radius, radarColor)
|
||||||
drawElevationLabels(radius, primaryColor, measurer)
|
drawElevationLabels(radius, primaryColor, measurer)
|
||||||
translate(center.x, center.y) {
|
translate(center.x, center.y) {
|
||||||
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
|
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
|
||||||
|
// Station-B overlay: full dashed track + directional arrows + live position dot
|
||||||
|
// (same display mode as the local station: track line/arrow + pulsing dot)
|
||||||
|
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
|
||||||
|
drawPath(
|
||||||
|
trackBPath, trackBColor,
|
||||||
|
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
|
||||||
|
)
|
||||||
|
drawPath(trackBPath, trackBColor, style = Stroke(pathEffect = trackBEffect))
|
||||||
|
}
|
||||||
|
trackBPosition?.let { posB ->
|
||||||
|
if (posB.elevation > 0) {
|
||||||
|
drawPosition(posB, radius, animScale, trackBColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (item.elevation > 0) {
|
if (item.elevation > 0) {
|
||||||
drawPosition(item, radius, animScale, primaryColor)
|
drawPosition(item, radius, animScale, primaryColor)
|
||||||
}
|
}
|
||||||
@@ -208,13 +233,25 @@ private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, b
|
|||||||
|
|
||||||
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
|
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
|
||||||
val trackPath = Path()
|
val trackPath = Path()
|
||||||
|
var lastAzim: Double? = null
|
||||||
positions.forEachIndexed { index, pos ->
|
positions.forEachIndexed { index, pos ->
|
||||||
val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
|
val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
|
||||||
if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
|
// Split the path when azimuth wraps 0°/360° to avoid a line across the plot
|
||||||
|
val wrap = lastAzim != null && abs(azimuthDeltaRad(pos.azimuth - lastAzim!!)) > PI
|
||||||
|
if (index == 0 || wrap) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
|
||||||
|
lastAzim = pos.azimuth
|
||||||
}
|
}
|
||||||
return trackPath
|
return trackPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalize an azimuth delta (radians) into the [-PI, PI] range. */
|
||||||
|
private fun azimuthDeltaRad(deltaRad: Double): Double {
|
||||||
|
var d = deltaRad % (2 * PI)
|
||||||
|
if (d > PI) d -= 2 * PI
|
||||||
|
if (d < -PI) d += 2 * PI
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
private fun createTrackEffect(trackPath: Path): PathEffect {
|
private fun createTrackEffect(trackPath: Path): PathEffect {
|
||||||
val shapeRadius = 24f
|
val shapeRadius = 24f
|
||||||
val angle = 120.0.toRadians()
|
val angle = 120.0.toRadians()
|
||||||
|
|||||||
@@ -65,14 +65,11 @@ class RadarViewModel(
|
|||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private var stationPos = settingsRepo.stationPosition.value
|
private val stationPos = settingsRepo.stationPosition.value
|
||||||
private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||||
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
|
|
||||||
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
|
|
||||||
private var transponders: List<SatRadio> = emptyList()
|
private var transponders: List<SatRadio> = emptyList()
|
||||||
private var sstvDecoder: SstvDecoder? = null
|
private var sstvDecoder: SstvDecoder? = null
|
||||||
private var sstvRecordingJob: Job? = null
|
private var sstvRecordingJob: Job? = null
|
||||||
private var sensorCollectionJob: Job? = null
|
|
||||||
|
|
||||||
// Celestial positions change slowly, recompute at most once per minute
|
// Celestial positions change slowly, recompute at most once per minute
|
||||||
private var lastCelestialUpdateMs = 0L
|
private var lastCelestialUpdateMs = 0L
|
||||||
@@ -91,67 +88,33 @@ class RadarViewModel(
|
|||||||
val uiState: StateFlow<RadarState> = _uiState
|
val uiState: StateFlow<RadarState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectSettingsChanges() // also handles initial sensor subscription
|
collectCompassSensor()
|
||||||
collectStationPositionChanges()
|
collectSettingsChanges()
|
||||||
collectPassAndStartTickLoop()
|
collectPassAndStartTickLoop()
|
||||||
collectRadioTrackingState()
|
collectRadioTrackingState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts sensor collection if not already running.
|
private fun collectCompassSensor() {
|
||||||
private fun startSensorCollection() {
|
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||||
if (sensorCollectionJob?.isActive == true) return
|
viewModelScope.launch {
|
||||||
sensorCollectionJob = viewModelScope.launch {
|
|
||||||
sensorsRepo.enableSensor()
|
sensorsRepo.enableSensor()
|
||||||
sensorsRepo.sensorData.collect { data ->
|
sensorsRepo.sensorData.collect { data ->
|
||||||
val orientationValues =
|
val orientationValues = (data.first + magDeclination) to data.second
|
||||||
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
|
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stops sensor collection and disables the hardware sensor.
|
|
||||||
private fun stopSensorCollection() {
|
|
||||||
sensorCollectionJob?.cancel()
|
|
||||||
sensorCollectionJob = null
|
|
||||||
sensorsRepo.disableSensor()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collectSettingsChanges() {
|
private fun collectSettingsChanges() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collect { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
compassOffset = settings.radarCompassOffset
|
|
||||||
compassOffsetElev = settings.radarCompassOffsetElev
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isUtc = settings.stateOfUtc,
|
isUtc = settings.stateOfUtc,
|
||||||
shouldShowSweep = settings.stateOfSweep,
|
shouldShowSweep = settings.stateOfSweep,
|
||||||
shouldUseCompass = settings.stateOfSensors,
|
shouldUseCompass = settings.stateOfSensors
|
||||||
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Reactively wire sensor hardware to the compass setting
|
|
||||||
when {
|
|
||||||
settings.stateOfSensors -> startSensorCollection()
|
|
||||||
else -> stopSensorCollection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collectStationPositionChanges() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.stationPosition.collect { newPos ->
|
|
||||||
if (stationPos != newPos) {
|
|
||||||
stationPos = newPos
|
|
||||||
magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
|
||||||
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
|
|
||||||
val pass = _uiState.value.currentPass ?: return@collect
|
|
||||||
if (!pass.isDeepSpace) {
|
|
||||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
|
||||||
_uiState.update { it.copy(satTrack = track) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +136,8 @@ class RadarViewModel(
|
|||||||
val passes = satelliteRepo.passes.value
|
val passes = satelliteRepo.passes.value
|
||||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||||
|
?: passes.find { it.catNum == catNum && aosTime in it.aosTime..it.losTime }
|
||||||
|
?: passes.find { it.catNum == catNum }
|
||||||
?: passes.firstOrNull()
|
?: passes.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +148,13 @@ class RadarViewModel(
|
|||||||
transponders = allRadios.filter { it.downlinkLow != null }
|
transponders = allRadios.filter { it.downlinkLow != null }
|
||||||
if (allRadios.isNotEmpty()) {
|
if (allRadios.isNotEmpty()) {
|
||||||
val firstUuid = allRadios.first().uuid
|
val firstUuid = allRadios.first().uuid
|
||||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
|
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
|
||||||
|
calculatorOffsetKHz = offsetKHz
|
||||||
|
)
|
||||||
|
}
|
||||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||||
}
|
}
|
||||||
if (!pass.isDeepSpace) {
|
if (!pass.isDeepSpace) {
|
||||||
@@ -250,7 +221,7 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
stopSensorCollection()
|
sensorsRepo.disableSensor()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
@@ -260,7 +231,6 @@ class RadarViewModel(
|
|||||||
// Compute toggle state before the update so we don't read post-update value
|
// Compute toggle state before the update so we don't read post-update value
|
||||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||||
val newUuid = if (isTogglingOff) null else action.uuid
|
val newUuid = if (isTogglingOff) null else action.uuid
|
||||||
// Load the saved offset for the newly selected satellite
|
|
||||||
val offsetKHz = if (!isTogglingOff) {
|
val offsetKHz = if (!isTogglingOff) {
|
||||||
transponders.find { it.uuid == action.uuid }
|
transponders.find { it.uuid == action.uuid }
|
||||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||||
@@ -319,6 +289,7 @@ class RadarViewModel(
|
|||||||
sstvDecoder?.clearPixels()
|
sstvDecoder?.clearPixels()
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is RadarAction.ChangeCalculatorOffset -> {
|
is RadarAction.ChangeCalculatorOffset -> {
|
||||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||||
transponders.find { it.uuid == uuid }?.catnum
|
transponders.find { it.uuid == uuid }?.catnum
|
||||||
@@ -455,7 +426,7 @@ class RadarViewModel(
|
|||||||
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
||||||
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
||||||
// - 0.50-0.60: Conservative, best for noisy environments
|
// - 0.50-0.60: Conservative, best for noisy environments
|
||||||
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
|
private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default
|
||||||
|
|
||||||
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
||||||
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
||||||
@@ -478,7 +449,7 @@ class RadarViewModel(
|
|||||||
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
||||||
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
||||||
// Ask users to compare both if they report line-skipping/compression artifacts.
|
// Ask users to compare both if they report line-skipping/compression artifacts.
|
||||||
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
|
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
|
||||||
|
|
||||||
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
||||||
private const val SSTV_INCLUDE_SCOPE = false
|
private const val SSTV_INCLUDE_SCOPE = false
|
||||||
@@ -490,6 +461,7 @@ class RadarViewModel(
|
|||||||
218.1, 225.7, 233.6, 241.8, 250.3
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -96,14 +96,14 @@ internal fun SstvPage(
|
|||||||
if (showModeDialog.value) {
|
if (showModeDialog.value) {
|
||||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||||
val dismiss = { showModeDialog.value = false }
|
val dismiss = { showModeDialog.value = false }
|
||||||
ConfirmDialog(
|
SharedDialog(
|
||||||
title = "SSTV Mode",
|
title = "SSTV Mode",
|
||||||
onAccept = dismiss,
|
onDismissRequest = dismiss,
|
||||||
onCancel = dismiss
|
onAccept = dismiss
|
||||||
) {
|
) { _ ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(0.69f)
|
.fillMaxHeight(0.7f)
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
|
|||||||
+56
-50
@@ -24,12 +24,14 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -43,7 +45,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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
|
||||||
@@ -53,17 +55,23 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
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.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.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
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
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -77,7 +85,6 @@ 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.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
@@ -127,8 +134,8 @@ fun CalculatorPage(
|
|||||||
transceivers: List<SatRadio>,
|
transceivers: List<SatRadio>,
|
||||||
selectedUuid: String?,
|
selectedUuid: String?,
|
||||||
orbitalPos: OrbitalPos?,
|
orbitalPos: OrbitalPos?,
|
||||||
onAction: (RadarAction) -> Unit,
|
|
||||||
calculatorOffsetKHz: String = "",
|
calculatorOffsetKHz: String = "",
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val calculatorTransceivers = remember(transceivers) {
|
val calculatorTransceivers = remember(transceivers) {
|
||||||
@@ -145,47 +152,44 @@ fun CalculatorPage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(8.dp),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
if (calculatorTransceivers.size > 1) {
|
if (calculatorTransceivers.size > 1) {
|
||||||
item {
|
FlowRow(
|
||||||
FlowRow(
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
) {
|
||||||
) {
|
calculatorTransceivers.forEach { radio ->
|
||||||
calculatorTransceivers.forEach { radio ->
|
FilterChip(
|
||||||
FilterChip(
|
selected = radio.uuid == selectedTransceiver.uuid,
|
||||||
selected = radio.uuid == selectedTransceiver.uuid,
|
onClick = {
|
||||||
onClick = {
|
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
||||||
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
},
|
||||||
},
|
label = {
|
||||||
label = {
|
Text(
|
||||||
Text(
|
text = transceiverTitle(radio),
|
||||||
text = transceiverTitle(radio),
|
maxLines = 1,
|
||||||
maxLines = 1,
|
overflow = TextOverflow.Ellipsis
|
||||||
overflow = TextOverflow.Ellipsis
|
)
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
DopplerFrequencyCalculator(
|
||||||
DopplerFrequencyCalculator(
|
transponder = selectedTransceiver,
|
||||||
transponder = selectedTransceiver,
|
orbitalPos = orbitalPos,
|
||||||
orbitalPos = orbitalPos,
|
offsetKHz = calculatorOffsetKHz,
|
||||||
offsetKHz = calculatorOffsetKHz,
|
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||||
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +261,7 @@ private fun TransceiverItem(
|
|||||||
.rotate(if (isExpanded) 270f else 90f)
|
.rotate(if (isExpanded) 270f else 90f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Title with mode
|
||||||
Text(
|
Text(
|
||||||
text = transceiverTitle(radio),
|
text = transceiverTitle(radio),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -377,7 +382,7 @@ private fun UnifiedFrequencyRow(
|
|||||||
private fun ExpandedRadioControl(
|
private fun ExpandedRadioControl(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit
|
onAction: (RadarAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -450,11 +455,10 @@ private fun ExpandedRadioControl(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
maxItemsInEachRow = 5,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
val chipModifier = Modifier.weight(1f)
|
val chipModifier = Modifier.width(64.dp)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioControl.ctcssTone == null,
|
selected = radioControl.ctcssTone == null,
|
||||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
@@ -530,10 +534,10 @@ private fun DopplerFrequencyCalculator(
|
|||||||
) {
|
) {
|
||||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||||
|
|
||||||
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
|
var lastEditedField by rememberSaveable(transponder.uuid) { mutableStateOf(EditedField.TX) }
|
||||||
var txFrequencyHz by remember { mutableStateOf(0L) }
|
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||||
var rxFrequencyHz by remember { mutableStateOf(0L) }
|
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||||
var passbandPosition by remember { mutableStateOf(0.5f) }
|
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
|
||||||
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
||||||
|
|
||||||
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||||
@@ -623,12 +627,15 @@ private fun DopplerFrequencyCalculator(
|
|||||||
fun updateRx(newRxHz: Long) {
|
fun updateRx(newRxHz: Long) {
|
||||||
when (lastEditedField) {
|
when (lastEditedField) {
|
||||||
EditedField.PASSBAND -> {
|
EditedField.PASSBAND -> {
|
||||||
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
|
// Passband 模式:从 RX 经完整往返路径(含多普勒补偿)反推 TX 位置
|
||||||
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
val txFromRx = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
|
newRxHz, rawTransponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
passbandPosition = if (txFromRx != null) {
|
passbandPosition = if (txFromRx != null) {
|
||||||
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
|
((txFromRx - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||||
} else pos
|
} else {
|
||||||
|
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||||
@@ -897,4 +904,3 @@ private fun transceiverTitle(radio: SatRadio): String {
|
|||||||
|
|
||||||
private val FREQ_ADJUSTMENTS =
|
private val FREQ_ADJUSTMENTS =
|
||||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
|
|
||||||
+11
-11
@@ -39,27 +39,27 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MultiModesDialogPreview() {
|
private fun MultiTypesDialogPreview() {
|
||||||
val modes = listOf("FM", "APT", "SSTV", "BPSK")
|
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
|
||||||
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
|
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MultiModesDialog(
|
internal fun MultiTypesDialog(
|
||||||
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val selected = remember { mutableStateOf(modes.toSet()) }
|
val selected = remember { mutableStateOf(types.toSet()) }
|
||||||
val toggle = { mode: String ->
|
val toggle = { type: String ->
|
||||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
selected.value = if (type in selected.value) selected.value - type else selected.value + type
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.value.toList()) }
|
val onAccept = { accept(selected.value.toList()) }
|
||||||
ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(240.dp),
|
columns = GridCells.Adaptive(240.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -68,7 +68,7 @@ internal fun MultiModesDialog(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allModes) { index, item ->
|
itemsIndexed(allTypes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+29
-72
@@ -62,11 +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.LocalSpacing
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
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
|
||||||
@@ -88,27 +87,27 @@ private fun SatellitesScreen(
|
|||||||
navigateUp: () -> Unit
|
navigateUp: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isDialogShown) {
|
if (uiState.isDialogShown) {
|
||||||
MultiModesDialog(
|
MultiTypesDialog(
|
||||||
allModes = uiState.modesList,
|
allTypes = uiState.typesList,
|
||||||
modes = uiState.currentModes,
|
types = uiState.currentTypes,
|
||||||
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
|
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
|
||||||
accept = { onAction(SatellitesAction.SelectModes(it)) }
|
accept = { onAction(SatellitesAction.SelectTypes(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWarning) {
|
if (uiState.shouldSeeWarning) {
|
||||||
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
||||||
InfoDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.sat_warning_title),
|
title = stringResource(R.string.sat_warning_title),
|
||||||
onDismiss = dismiss,
|
onDismissRequest = dismiss,
|
||||||
onAccept = dismiss
|
onAccept = dismiss,
|
||||||
) {
|
titleFontSize = 18
|
||||||
|
) { padding ->
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sat_warning_message),
|
text = stringResource(R.string.sat_warning_message),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +122,9 @@ private fun SatellitesScreen(
|
|||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
if (isVerticalLayout()) {
|
if (isVerticalLayout()) {
|
||||||
TopBar {
|
TopBar {
|
||||||
ModeCard(
|
TypeCard(
|
||||||
modes = uiState.currentModes,
|
types = uiState.currentTypes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(
|
PrimaryIconCard(
|
||||||
@@ -157,9 +156,9 @@ private fun SatellitesScreen(
|
|||||||
resId = R.drawable.ic_done,
|
resId = R.drawable.ic_done,
|
||||||
modifier = Modifier.semantics { contentDescription = primCardCd }
|
modifier = Modifier.semantics { contentDescription = primCardCd }
|
||||||
)
|
)
|
||||||
ModeCard(
|
TypeCard(
|
||||||
modes = uiState.currentModes,
|
types = uiState.currentTypes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
SearchBar(
|
SearchBar(
|
||||||
@@ -241,8 +240,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -252,12 +251,12 @@ private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifie
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_radios),
|
painter = painterResource(id = R.drawable.ic_tags),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sat_type_hint, modesText),
|
text = stringResource(R.string.sat_type_hint, typesText),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -291,14 +290,17 @@ private fun Satellite(
|
|||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
|
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
|
||||||
) {
|
) {
|
||||||
Text(text = "$passSatId - ", color = MaterialTheme.colorScheme.primary)
|
Text(
|
||||||
|
text = "$passSatId - ",
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.name,
|
text = item.name,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = item.isSelected,
|
checked = item.isSelected,
|
||||||
@@ -323,54 +325,9 @@ private fun SatellitesPreview() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
||||||
val selectedItems = items.filter { it.isSelected }
|
|
||||||
val availableItems = items.filterNot { it.isSelected }
|
|
||||||
|
|
||||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
||||||
stickyHeader(key = "sat_summary_header") {
|
items(items = items, key = { item -> item.catnum }) { entry ->
|
||||||
SummaryHeader(
|
Satellite(entry, onSelected, Modifier.animateItem())
|
||||||
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
|
|
||||||
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedItems.isNotEmpty()) {
|
|
||||||
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
|
|
||||||
Satellite(entry, onSelected, Modifier.animateItem())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableItems.isNotEmpty()) {
|
|
||||||
items(items = availableItems, key = { item -> item.catnum }) { entry ->
|
|
||||||
Satellite(entry, onSelected, Modifier.animateItem())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SummaryHeader(selectedText: String, availableText: String) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = selectedText,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = availableText,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
+4
-4
@@ -24,8 +24,8 @@ data class SatellitesState(
|
|||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val shouldSeeWarning: Boolean = false,
|
val shouldSeeWarning: Boolean = false,
|
||||||
val itemsList: List<SatItem> = emptyList(),
|
val itemsList: List<SatItem> = emptyList(),
|
||||||
val currentModes: List<String> = emptyList(),
|
val currentTypes: List<String> = emptyList(),
|
||||||
val modesList: List<String> = emptyList()
|
val typesList: List<String> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SatellitesAction {
|
sealed interface SatellitesAction {
|
||||||
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
|
|||||||
data class SearchFor(val query: String) : SatellitesAction
|
data class SearchFor(val query: String) : SatellitesAction
|
||||||
data object SelectAll : SatellitesAction
|
data object SelectAll : SatellitesAction
|
||||||
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
||||||
data class SelectModes(val modes: List<String>) : SatellitesAction
|
data class SelectTypes(val types: List<String>) : SatellitesAction
|
||||||
data object ToggleModesDialog : SatellitesAction
|
data object ToggleTypesDialog : SatellitesAction
|
||||||
data object UnselectAll : SatellitesAction
|
data object UnselectAll : SatellitesAction
|
||||||
}
|
}
|
||||||
+10
-15
@@ -35,13 +35,13 @@ class SatellitesViewModel(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val defaultModes = selectionRepo.getCurrentModes()
|
private val defaultTypes = selectionRepo.getCurrentTypes()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
SatellitesState(
|
SatellitesState(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
||||||
currentModes = defaultModes,
|
currentTypes = defaultTypes,
|
||||||
modesList = selectionRepo.getModesList()
|
typesList = selectionRepo.getTypesList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val uiState: StateFlow<SatellitesState> = _uiState
|
val uiState: StateFlow<SatellitesState> = _uiState
|
||||||
@@ -49,7 +49,7 @@ class SatellitesViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
selectionRepo.setQuery(String())
|
selectionRepo.setQuery(String())
|
||||||
selectionRepo.setModes(defaultModes)
|
selectionRepo.setTypes(defaultTypes)
|
||||||
selectionRepo.getEntriesFlow().collectLatest { items ->
|
selectionRepo.getEntriesFlow().collectLatest { items ->
|
||||||
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
||||||
}
|
}
|
||||||
@@ -59,11 +59,6 @@ class SatellitesViewModel(
|
|||||||
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.selectedSatModes.collectLatest { modes ->
|
|
||||||
_uiState.update { it.copy(currentModes = modes) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: SatellitesAction) {
|
fun onAction(action: SatellitesAction) {
|
||||||
@@ -73,8 +68,8 @@ class SatellitesViewModel(
|
|||||||
is SatellitesAction.SearchFor -> searchFor(action.query)
|
is SatellitesAction.SearchFor -> searchFor(action.query)
|
||||||
SatellitesAction.SelectAll -> selectAll(true)
|
SatellitesAction.SelectAll -> selectAll(true)
|
||||||
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
||||||
is SatellitesAction.SelectModes -> selectModes(action.modes)
|
is SatellitesAction.SelectTypes -> selectTypes(action.types)
|
||||||
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
|
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
|
||||||
SatellitesAction.UnselectAll -> selectAll(false)
|
SatellitesAction.UnselectAll -> selectAll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,12 +86,12 @@ class SatellitesViewModel(
|
|||||||
selectionRepo.setSelection(listOf(id), isTicked.not())
|
selectionRepo.setSelection(listOf(id), isTicked.not())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectModes(modes: List<String>) = viewModelScope.launch {
|
private fun selectTypes(types: List<String>) = viewModelScope.launch {
|
||||||
selectionRepo.setModes(modes)
|
selectionRepo.setTypes(types)
|
||||||
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
|
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleModesDialog() {
|
private fun toggleTypesDialog() {
|
||||||
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+155
-197
@@ -17,55 +17,56 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
import androidx.compose.animation.core.spring
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.toMutableStateList
|
|
||||||
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.rotate
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -79,28 +80,19 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
|
|||||||
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
|
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
|
||||||
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() } }
|
||||||
ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
||||||
Row(
|
OutlinedTextField(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
value = latValue.value,
|
||||||
modifier = Modifier
|
onValueChange = { latValue.value = it },
|
||||||
.fillMaxWidth()
|
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
|
||||||
.padding(horizontal = LocalSpacing.current.large)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
) {
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = latValue.value,
|
value = lonValue.value,
|
||||||
onValueChange = { latValue.value = it },
|
onValueChange = { lonValue.value = it },
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
|
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
|
||||||
singleLine = true,
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
modifier = Modifier.weight(1f),
|
)
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = lonValue.value,
|
|
||||||
onValueChange = { lonValue.value = it },
|
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,18 +115,12 @@ private fun LocatorDialogPreview() {
|
|||||||
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
||||||
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
||||||
val onAccept = { save(locator.value).also { dismiss() } }
|
val onAccept = { save(locator.value).also { dismiss() } }
|
||||||
ConfirmDialog(
|
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
|
||||||
title = stringResource(R.string.prefs_locator_title),
|
|
||||||
onCancel = dismiss,
|
|
||||||
onAccept = onAccept
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = locator.value,
|
value = locator.value,
|
||||||
onValueChange = { locator.value = it },
|
onValueChange = { locator.value = it },
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
|
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = LocalSpacing.current.large),
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
@@ -170,7 +156,7 @@ fun DataSourcesDialog(
|
|||||||
onSave: (List<String>, List<String>) -> Unit
|
onSave: (List<String>, List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
|
// Stable Long IDs avoid key collisions when several entries are empty or duplicated.
|
||||||
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
||||||
val satUrls = remember {
|
val satUrls = remember {
|
||||||
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
|
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
|
||||||
@@ -178,8 +164,14 @@ fun DataSourcesDialog(
|
|||||||
val txUrls = remember {
|
val txUrls = remember {
|
||||||
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
||||||
}
|
}
|
||||||
val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() }
|
val onAccept = {
|
||||||
ConfirmDialog(
|
onSave(
|
||||||
|
satUrls.map { it.second.trim() }.filter { it.isNotBlank() },
|
||||||
|
txUrls.map { it.second.trim() }.filter { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
SharedDialog(
|
||||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
title = stringResource(id = R.string.prefs_data_sources_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept,
|
onAccept = onAccept,
|
||||||
@@ -210,7 +202,7 @@ fun DataSourcesDialog(
|
|||||||
}
|
}
|
||||||
sourceSection(
|
sourceSection(
|
||||||
sectionKey = "sat",
|
sectionKey = "sat",
|
||||||
label = "Satellites data",
|
labelResId = R.string.prefs_data_sources_satellites_label,
|
||||||
urls = satUrls,
|
urls = satUrls,
|
||||||
onAdd = { satUrls.add(nextId.longValue++ to "") },
|
onAdd = { satUrls.add(nextId.longValue++ to "") },
|
||||||
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
|
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
|
||||||
@@ -219,7 +211,7 @@ fun DataSourcesDialog(
|
|||||||
)
|
)
|
||||||
sourceSection(
|
sourceSection(
|
||||||
sectionKey = "tx",
|
sectionKey = "tx",
|
||||||
label = "Transceivers data",
|
labelResId = R.string.prefs_data_sources_transceivers_label,
|
||||||
urls = txUrls,
|
urls = txUrls,
|
||||||
onAdd = { txUrls.add(nextId.longValue++ to "") },
|
onAdd = { txUrls.add(nextId.longValue++ to "") },
|
||||||
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
|
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
|
||||||
@@ -232,7 +224,7 @@ fun DataSourcesDialog(
|
|||||||
|
|
||||||
private fun LazyListScope.sourceSection(
|
private fun LazyListScope.sourceSection(
|
||||||
sectionKey: String,
|
sectionKey: String,
|
||||||
label: String,
|
labelResId: Int,
|
||||||
urls: List<Pair<Long, String>>,
|
urls: List<Pair<Long, String>>,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onMoveUp: (Int) -> Unit,
|
onMoveUp: (Int) -> Unit,
|
||||||
@@ -243,12 +235,12 @@ private fun LazyListScope.sourceSection(
|
|||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = stringResource(labelResId),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
IconCard(action = onAdd, resId = R.drawable.ic_add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
|
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
|
||||||
@@ -256,9 +248,9 @@ private fun LazyListScope.sourceSection(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = url,
|
value = url,
|
||||||
onValueChange = { onUrlChange(index, it) },
|
onValueChange = { onUrlChange(index, it) },
|
||||||
label = { Text("Source URL") },
|
label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
IconButton(onClick = { onMoveUp(index) }) {
|
IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_arrow),
|
painter = painterResource(R.drawable.ic_arrow),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -344,7 +336,7 @@ fun NetworkOutputDialog(
|
|||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
ConfirmDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.prefs_net_title),
|
title = stringResource(R.string.prefs_net_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
@@ -385,10 +377,7 @@ fun NetworkOutputDialog(
|
|||||||
onClick = { frequencyOffsetHz.value = "0" },
|
onClick = { frequencyOffsetHz.value = "0" },
|
||||||
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
|
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(painter = painterResource(R.drawable.ic_close), contentDescription = null)
|
||||||
painter = painterResource(R.drawable.ic_close),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -448,15 +437,11 @@ fun BluetoothOutputDialog(
|
|||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
|
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
|
||||||
val rotatorAddress =
|
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
|
||||||
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
||||||
val frequencyState =
|
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
||||||
val frequencyAddress =
|
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
|
||||||
val frequencyFormat =
|
|
||||||
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
||||||
@@ -464,7 +449,7 @@ fun BluetoothOutputDialog(
|
|||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
ConfirmDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.prefs_bt_title),
|
title = stringResource(R.string.prefs_bt_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
@@ -545,22 +530,23 @@ private fun OutputChannelSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RadioControlDialog(
|
fun RadioControlDialog(
|
||||||
initialSettings: RadioControlSettings,
|
initialSettings: RadioControlSettings,
|
||||||
pairedBluetoothDevices: List<Pair<String, String>>,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (RadioControlSettings) -> Unit
|
onSave: (RadioControlSettings) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
val padding = LocalSpacing.current.large
|
||||||
|
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||||
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||||
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
||||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||||
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||||
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||||
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
||||||
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
||||||
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||||
|
|
||||||
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
@@ -570,157 +556,157 @@ fun RadioControlDialog(
|
|||||||
if (!isIcom && splitMode.value) splitMode.value = false
|
if (!isIcom && splitMode.value) splitMode.value = false
|
||||||
|
|
||||||
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
||||||
else RadioControlSettings.BAUD_RATES_YAESU
|
else RadioControlSettings.BAUD_RATES_YAESU
|
||||||
|
|
||||||
// If current baud rate is not in the new list, default to the first available
|
// If current baud rate is not in the new list, default to the first available
|
||||||
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
||||||
|
|
||||||
|
val pairedDevices: List<Pair<String, String>> = remember {
|
||||||
|
buildList {
|
||||||
|
try {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
manager.adapter?.bondedDevices?.forEach {
|
||||||
|
add(Pair(it.name ?: "Unknown", it.address ?: ""))
|
||||||
|
}
|
||||||
|
} catch (_: SecurityException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
RadioControlSettings(
|
RadioControlSettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
radioModel = radioModel.value,
|
radioModel = radioModel.value,
|
||||||
txRadioAddress = txAddress.value,
|
txRadioAddress = txAddress.value,
|
||||||
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
||||||
txRadioName = txName.value,
|
txRadioName = txName.value,
|
||||||
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
||||||
baudRate = baudRate.intValue,
|
baudRate = baudRate.intValue,
|
||||||
splitMode = splitMode.value
|
splitMode = splitMode.value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfirmDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.rc_settings_title),
|
title = stringResource(R.string.rc_settings_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||||
|
|
||||||
|
// Enable switch
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.rc_enable_switch))
|
Text(stringResource(R.string.rc_enable_switch))
|
||||||
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// Radio model — FlowRow so chips wrap on small screens
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.rc_radio_model),
|
text = stringResource(R.string.rc_radio_model),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Row(
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioModel.value == model,
|
selected = radioModel.value == model,
|
||||||
onClick = { radioModel.value = model },
|
onClick = { radioModel.value = model },
|
||||||
label = {
|
label = { Text(model, fontSize = 12.sp) },
|
||||||
Text(
|
enabled = enabled.value
|
||||||
text = compactRadioModelLabel(model),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = enabled.value,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
val isSplitModeAvailable = enabled.value && isIcom
|
// IC-705 split-mode toggle (only shown for IC-705)
|
||||||
val splitModeLabelColor = if (isSplitModeAvailable) {
|
if (isIcom) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
Row(
|
||||||
} else {
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
|
||||||
|
Text(
|
||||||
|
text = "Use VFO-A/B split on one IC-705 instead of two radios",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = splitMode.value,
|
||||||
|
onCheckedChange = { splitMode.value = it },
|
||||||
|
enabled = enabled.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Split mode (IC-705 only)",
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = splitModeLabelColor
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = splitMode.value,
|
|
||||||
onCheckedChange = { splitMode.value = it },
|
|
||||||
enabled = isSplitModeAvailable
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
|
|
||||||
Text("Radio devices", fontWeight = FontWeight.Medium)
|
// TX Radio (always shown; in split mode this is the single IC-705)
|
||||||
Row(
|
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
Text(txLabel, fontWeight = FontWeight.Medium)
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
CardButton(
|
|
||||||
onClick = { selectingFor.value = "tx" },
|
|
||||||
text = if (isSingleRadio) "Select Radio" else "Select TX",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
CardButton(
|
|
||||||
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
|
|
||||||
text = if (isSingleRadio) "RX = TX" else "Select RX",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (txAddress.value.isNotBlank()) {
|
if (txAddress.value.isNotBlank()) {
|
||||||
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
||||||
}
|
|
||||||
if (isSingleRadio && txAddress.value.isNotBlank()) {
|
|
||||||
Text("RX: same as TX", fontSize = 13.sp)
|
|
||||||
} else if (rxAddress.value.isNotBlank()) {
|
|
||||||
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
|
||||||
}
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { selectingFor.value = "tx" },
|
||||||
|
text = if (isSingleRadio) "Select Device" else "Select TX Device",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// RX Radio (hidden in split mode — the same radio handles both)
|
||||||
|
if (!isSingleRadio) {
|
||||||
|
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
|
||||||
|
if (rxAddress.value.isNotBlank()) {
|
||||||
|
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { selectingFor.value = "rx" },
|
||||||
|
text = "Select RX Device",
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paired device picker (inline, shown while selecting)
|
||||||
if (selectingFor.value.isNotBlank()) {
|
if (selectingFor.value.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Paired Bluetooth Devices:",
|
text = "Paired Bluetooth Devices:",
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
if (pairedBluetoothDevices.isEmpty()) {
|
if (pairedDevices.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
||||||
fontSize = 13.sp
|
fontSize = 13.sp
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
pairedBluetoothDevices.forEach { (name, address) ->
|
pairedDevices.forEach { (name, address) ->
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
if (selectingFor.value == "tx") {
|
if (selectingFor.value == "tx") {
|
||||||
txAddress.value = address
|
txAddress.value = address
|
||||||
txName.value = name
|
txName.value = name
|
||||||
if (isSingleRadio) {
|
|
||||||
rxAddress.value = address
|
|
||||||
rxName.value = name
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
rxAddress.value = address
|
rxAddress.value = address
|
||||||
rxName.value = name
|
rxName.value = name
|
||||||
}
|
}
|
||||||
selectingFor.value = ""
|
selectingFor.value = ""
|
||||||
}
|
}
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(name, modifier = Modifier.weight(1f))
|
Text(name, modifier = Modifier.weight(1f))
|
||||||
@@ -732,26 +718,15 @@ fun RadioControlDialog(
|
|||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Baud rate — FlowRow so all chips fit on narrow screens
|
||||||
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
||||||
FlowRow(
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
maxItemsInEachRow = 6,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
baudRates.forEach { rate ->
|
baudRates.forEach { rate ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = rate == baudRate.intValue,
|
selected = rate == baudRate.intValue,
|
||||||
onClick = { baudRate.intValue = rate },
|
onClick = { baudRate.intValue = rate },
|
||||||
label = {
|
label = { Text(rate.toString(), fontSize = 12.sp) },
|
||||||
Text(
|
enabled = enabled.value
|
||||||
text = baudLabel(rate),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = enabled.value,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -759,20 +734,3 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun compactRadioModelLabel(model: String): String = when (model) {
|
|
||||||
RadioControlSettings.MODEL_YAESU_FT817 -> "FT-817/818"
|
|
||||||
RadioControlSettings.MODEL_YAESU_FT857 -> "FT-857/897"
|
|
||||||
RadioControlSettings.MODEL_ICOM_IC705 -> "IC-705"
|
|
||||||
else -> model
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun baudLabel(rate: Int): String = when (rate) {
|
|
||||||
4800 -> "4k"
|
|
||||||
9600 -> "9k"
|
|
||||||
19200 -> "19k"
|
|
||||||
38400 -> "38k"
|
|
||||||
57600 -> "57k"
|
|
||||||
115200 -> "115k"
|
|
||||||
else -> rate.toString()
|
|
||||||
}
|
|
||||||
+62
-111
@@ -25,12 +25,10 @@ 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
|
||||||
@@ -40,7 +38,6 @@ import androidx.compose.material3.ElevatedCard
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -75,7 +72,6 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
|||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -94,10 +90,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 }
|
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
|
||||||
@@ -169,14 +177,10 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
if (dialogs.radioControl) {
|
if (dialogs.radioControl) {
|
||||||
RadioControlDialog(
|
RadioControlDialog(
|
||||||
initialSettings = uiState.radioControlSettings,
|
initialSettings = uiState.radioControlSettings,
|
||||||
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
|
|
||||||
onDismiss = { dialogs.radioControl = false },
|
onDismiss = { dialogs.radioControl = false },
|
||||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (dialogs.whatsNew) {
|
|
||||||
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// URLs for top bar
|
// URLs for top bar
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
@@ -197,11 +201,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
if (isVerticalLayout) {
|
if (isVerticalLayout) {
|
||||||
TopBar {
|
TopBar {
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { dialogs.whatsNew = true },
|
onClick = { safeOpenUri(appUrl) },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
}
|
}
|
||||||
TopBar {
|
TopBar {
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -223,9 +227,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { dialogs.whatsNew = true },
|
onClick = { safeOpenUri(appUrl) },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -281,22 +285,8 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onRadioControlClick = { dialogs.radioControl = true }
|
onRadioControlClick = { dialogs.radioControl = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item { OtherCard(uiState.otherSettings, onAction) }
|
||||||
if (isVerticalLayout) {
|
item { CardCredits() }
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
OtherCard(uiState.otherSettings, onAction)
|
|
||||||
CardCredits()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
|
|
||||||
) {
|
|
||||||
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
|
|
||||||
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,83 +465,40 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
shouldSeeWarning = false,
|
shouldSeeWarning = false,
|
||||||
shouldSeeWhatsNew = false
|
shouldSeeWhatsNew = false
|
||||||
)
|
)
|
||||||
OtherCard(settings = values, onAction = {})
|
OtherCard(settings = values) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
|
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
ElevatedCard(
|
||||||
|
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
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
||||||
// Display preferences: UTC clock + night filter
|
onAction(SettingsAction.ToggleUtc(it))
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
|
||||||
onAction(SettingsAction.ToggleUtc(it))
|
|
||||||
}
|
|
||||||
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
// Radar behavior: sweep animation + sensor control
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
|
||||||
onAction(SettingsAction.ToggleSweep(it))
|
|
||||||
}
|
|
||||||
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
|
||||||
onAction(SettingsAction.ToggleSensor(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
// Data management (full width)
|
|
||||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||||
onAction(SettingsAction.ToggleUpdate(it))
|
onAction(SettingsAction.ToggleUpdate(it))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||||
// Compass calibration sliders at the bottom
|
onAction(SettingsAction.ToggleSweep(it))
|
||||||
CompassOffsetRow(
|
}
|
||||||
labelResId = R.string.prefs_other_compass_offset,
|
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
||||||
value = settings.radarCompassOffset,
|
onAction(SettingsAction.ToggleSensor(it))
|
||||||
range = -180f..180f
|
}
|
||||||
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
onAction(SettingsAction.ToggleNightMode(it))
|
||||||
CompassOffsetRow(
|
}
|
||||||
labelResId = R.string.prefs_other_compass_offset_elev,
|
|
||||||
value = settings.radarCompassOffsetElev,
|
|
||||||
range = -90f..90f
|
|
||||||
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CompassOffsetRow(
|
|
||||||
labelResId: Int,
|
|
||||||
value: Float,
|
|
||||||
range: ClosedFloatingPointRange<Float>,
|
|
||||||
onValueChange: (Float) -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = labelResId))
|
|
||||||
Text(text = "${value.toInt()}°")
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Slider(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
valueRange = range
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -564,21 +511,6 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = labelResId),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatUpdateTime(updateTime: Long): String {
|
private fun formatUpdateTime(updateTime: Long): String {
|
||||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||||
@@ -613,6 +545,7 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
|||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.height(268.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -646,7 +579,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_sputnik),
|
painter = painterResource(id = R.drawable.ic_satellites),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -697,7 +630,6 @@ private class DialogVisibility {
|
|||||||
var network by mutableStateOf(false)
|
var network by mutableStateOf(false)
|
||||||
var bluetooth by mutableStateOf(false)
|
var bluetooth by mutableStateOf(false)
|
||||||
var radioControl by mutableStateOf(false)
|
var radioControl by mutableStateOf(false)
|
||||||
var whatsNew by mutableStateOf(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -705,12 +637,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
return rememberSaveable(saver = run {
|
return rememberSaveable(saver = run {
|
||||||
androidx.compose.runtime.saveable.Saver(
|
androidx.compose.runtime.saveable.Saver(
|
||||||
save = {
|
save = {
|
||||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
|
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
|
||||||
},
|
},
|
||||||
restore = {
|
restore = {
|
||||||
DialogVisibility().apply {
|
DialogVisibility().apply {
|
||||||
position = it[0]; locator = it[1]; dataSources = it[2]
|
position = it[0]; locator = it[1]; dataSources = it[2]
|
||||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
|
network = it[3]; bluetooth = it[4]; radioControl = it[5]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -727,14 +659,17 @@ private class SettingsPermissions(
|
|||||||
val launchTleImport: () -> Unit,
|
val launchTleImport: () -> Unit,
|
||||||
val launchTransceiverImport: () -> Unit,
|
val launchTransceiverImport: () -> Unit,
|
||||||
val launchBluetooth: () -> Unit,
|
val launchBluetooth: () -> Unit,
|
||||||
val launchNetwork: () -> 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
|
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(
|
||||||
@@ -771,6 +706,15 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(networkError))
|
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 = {
|
||||||
@@ -787,6 +731,13 @@ private fun rememberSettingsPermissions(
|
|||||||
} else {
|
} else {
|
||||||
onNetworkGranted()
|
onNetworkGranted()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
launchCustomSourcesPermission = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||||
|
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
} else {
|
||||||
|
onCustomSourcesPermissionGranted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
@@ -41,8 +41,7 @@ data class SettingsState(
|
|||||||
val otherSettings: OtherSettings,
|
val otherSettings: OtherSettings,
|
||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
val radioControlSettings: RadioControlSettings,
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings,
|
val dataSourcesSettings: DataSourcesSettings
|
||||||
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -65,8 +64,6 @@ sealed interface SettingsAction {
|
|||||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||||
data class SetRadarCompassOffset(val value: Float) : SettingsAction
|
|
||||||
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
|
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+1
-6
@@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val container: IMainContainer,
|
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
@@ -48,8 +47,7 @@ class SettingsViewModel(
|
|||||||
otherSettings = settingsRepo.otherSettings.value,
|
otherSettings = settingsRepo.otherSettings.value,
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
pairedBluetoothDevices = container.providePairedBluetoothDevices()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,8 +121,6 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
|
||||||
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
|
||||||
// Remote control & data sources
|
// Remote control & data sources
|
||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
@@ -208,7 +204,6 @@ class SettingsViewModel(
|
|||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
container = container,
|
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast()
|
||||||
|
|||||||
+303
-6
@@ -1,5 +1,6 @@
|
|||||||
package com.rtbishop.look4sat.feature.status
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -20,12 +21,18 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
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.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -44,6 +51,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
@@ -51,14 +61,32 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
||||||
private val TILE_WIDTH: Dp = 64.dp
|
private val TILE_WIDTH: Dp = 64.dp
|
||||||
|
|
||||||
|
private data class UploadOption(
|
||||||
|
val apiValue: String,
|
||||||
|
val labelResId: Int,
|
||||||
|
val color: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
private val UPLOAD_OPTIONS = listOf(
|
||||||
|
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
|
||||||
|
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
|
||||||
|
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
|
||||||
|
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map AMSAT status text to Material3 colorScheme colors.
|
* Map AMSAT status text to Material3 colorScheme colors.
|
||||||
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
||||||
@@ -82,12 +110,49 @@ fun SatStatusDestination() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SatStatusScreen(uiState) { viewModel.refresh() }
|
DisposableEffect(lifecycleOwner, viewModel) {
|
||||||
|
var skipInitialStart = true
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_START) {
|
||||||
|
if (skipInitialStart) {
|
||||||
|
skipInitialStart = false
|
||||||
|
} else {
|
||||||
|
viewModel.fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
SatStatusScreen(
|
||||||
|
uiState = uiState,
|
||||||
|
refresh = viewModel::refresh,
|
||||||
|
onToggleUpload = viewModel::toggleUploadPanel,
|
||||||
|
onUploadReportChange = viewModel::setUploadReport,
|
||||||
|
onUploadCallsignChange = viewModel::setUploadCallsign,
|
||||||
|
onUploadGridChange = viewModel::setUploadGrid,
|
||||||
|
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
|
||||||
|
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
|
||||||
|
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
|
||||||
|
onDismissUpload = viewModel::collapseUploadPanel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
private fun SatStatusScreen(
|
||||||
|
uiState: SatStatusUiState,
|
||||||
|
refresh: () -> Unit,
|
||||||
|
onToggleUpload: () -> Unit,
|
||||||
|
onUploadReportChange: (String) -> Unit,
|
||||||
|
onUploadCallsignChange: (String) -> Unit,
|
||||||
|
onUploadGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitUpload: () -> Unit,
|
||||||
|
onConfirmSubmitUpload: (String) -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
|
onDismissUpload: () -> Unit
|
||||||
|
) {
|
||||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||||
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
||||||
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
||||||
@@ -122,7 +187,23 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedDay?.let { (status, day) ->
|
selectedDay?.let { (status, day) ->
|
||||||
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
|
ReportDialog(
|
||||||
|
statusName = status.name,
|
||||||
|
day = day,
|
||||||
|
reports = uiState.reports,
|
||||||
|
upload = uiState.upload,
|
||||||
|
onToggleUpload = onToggleUpload,
|
||||||
|
onReportChange = onUploadReportChange,
|
||||||
|
onCallsignChange = onUploadCallsignChange,
|
||||||
|
onGridChange = onUploadGridChange,
|
||||||
|
onRequestSubmitReport = onRequestSubmitUpload,
|
||||||
|
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
|
||||||
|
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
|
||||||
|
onDismiss = {
|
||||||
|
selectedDay = null
|
||||||
|
onDismissUpload()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,11 +359,55 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
|||||||
|
|
||||||
/** Report detail dialog (callsign / date / time / grid) */
|
/** Report detail dialog (callsign / date / time / grid) */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
|
private fun ReportDialog(
|
||||||
|
statusName: String,
|
||||||
|
day: SatDay,
|
||||||
|
reports: Map<String, SatReport>,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onToggleUpload: () -> Unit,
|
||||||
|
onReportChange: (String) -> Unit,
|
||||||
|
onCallsignChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitReport: () -> Unit,
|
||||||
|
onConfirmSubmitReport: () -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||||
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
|
InfoDialog(
|
||||||
|
title = "$statusName · ${day.dateLabel}",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onAccept = onDismiss,
|
||||||
|
extraAction = {
|
||||||
|
CardButton(
|
||||||
|
onClick = onToggleUpload,
|
||||||
|
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = upload.isExpanded) {
|
||||||
|
AmSatUploadPanel(
|
||||||
|
statusName = statusName,
|
||||||
|
upload = upload,
|
||||||
|
onReportChange = onReportChange,
|
||||||
|
onCallsignChange = onCallsignChange,
|
||||||
|
onGridChange = onGridChange,
|
||||||
|
onRequestSubmitReport = onRequestSubmitReport
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (upload.isConfirmingSubmit) {
|
||||||
|
AmSatUploadConfirmDialog(
|
||||||
|
statusName = statusName,
|
||||||
|
upload = upload,
|
||||||
|
onConfirm = onConfirmSubmitReport,
|
||||||
|
onDismiss = onDismissSubmitConfirmation
|
||||||
|
)
|
||||||
|
}
|
||||||
if (dayReports.isEmpty()) {
|
if (dayReports.isEmpty()) {
|
||||||
Text(stringResource(id = R.string.amsat_no_reports))
|
Text(
|
||||||
|
text = stringResource(id = R.string.amsat_no_reports),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
||||||
@@ -312,6 +437,171 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AmSatUploadPanel(
|
||||||
|
statusName: String,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onReportChange: (String) -> Unit,
|
||||||
|
onCallsignChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitReport: () -> Unit
|
||||||
|
) {
|
||||||
|
val options = remember(statusName) {
|
||||||
|
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_report),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_status),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
options.forEach { option ->
|
||||||
|
val selected = upload.selectedReport == option.apiValue
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onReportChange(option.apiValue) },
|
||||||
|
label = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) Color.White else option.color)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(stringResource(option.labelResId), maxLines = 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
containerColor = option.color.copy(alpha = 0.10f),
|
||||||
|
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
selectedContainerColor = option.color,
|
||||||
|
selectedLabelColor = Color.White
|
||||||
|
),
|
||||||
|
border = FilterChipDefaults.filterChipBorder(
|
||||||
|
enabled = true,
|
||||||
|
selected = selected,
|
||||||
|
borderColor = option.color.copy(alpha = 0.60f),
|
||||||
|
selectedBorderColor = option.color
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = upload.callsign,
|
||||||
|
onValueChange = onCallsignChange,
|
||||||
|
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = upload.gridSquare,
|
||||||
|
onValueChange = onGridChange,
|
||||||
|
label = { Text(stringResource(R.string.amsat_upload_grid)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onRequestSubmitReport,
|
||||||
|
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (upload.isSubmitting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.amsat_upload_submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload.message?.let { message ->
|
||||||
|
if (message == UPLOAD_MESSAGE_SUCCESS) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_success),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload.error?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = uploadErrorText(error),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AmSatUploadConfirmDialog(
|
||||||
|
statusName: String,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
|
||||||
|
val statusLabel = stringResource(option.labelResId)
|
||||||
|
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.amsat_upload_confirm_message,
|
||||||
|
statusName,
|
||||||
|
statusLabel,
|
||||||
|
upload.callsign,
|
||||||
|
grid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.amsat_upload_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun uploadErrorText(error: String): String {
|
||||||
|
return when (error) {
|
||||||
|
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
|
||||||
|
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
|
||||||
|
else -> stringResource(R.string.amsat_upload_failed, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
|
||||||
private fun formatFetchedAt(utcMs: Long): String {
|
private fun formatFetchedAt(utcMs: Long): String {
|
||||||
@@ -325,3 +615,10 @@ private fun formatFetchedAt(utcMs: Long): String {
|
|||||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
||||||
return "$day$month $year - $hh:$mm:$ss"
|
return "$day$month $year - $hh:$mm:$ss"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatUtcNowForUpload(utcMs: Long): String {
|
||||||
|
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
return formatter.format(Date(utcMs))
|
||||||
|
}
|
||||||
+222
-23
@@ -4,14 +4,38 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
const val AMSAT_REPORT_HEARD = "Heard"
|
||||||
|
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
|
||||||
|
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
|
||||||
|
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
|
||||||
|
|
||||||
|
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
|
||||||
|
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
|
||||||
|
const val UPLOAD_MESSAGE_SUCCESS = "success"
|
||||||
|
|
||||||
|
data class AmSatUploadUiState(
|
||||||
|
val isExpanded: Boolean = false,
|
||||||
|
val selectedReport: String = AMSAT_REPORT_HEARD,
|
||||||
|
val callsign: String = "",
|
||||||
|
val gridSquare: String = "",
|
||||||
|
val isSubmitting: Boolean = false,
|
||||||
|
val isConfirmingSubmit: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
data class SatStatusUiState(
|
data class SatStatusUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -19,35 +43,34 @@ data class SatStatusUiState(
|
|||||||
val statuses: List<SatStatus> = emptyList(),
|
val statuses: List<SatStatus> = emptyList(),
|
||||||
val reports: Map<String, SatReport> = emptyMap(),
|
val reports: Map<String, SatReport> = emptyMap(),
|
||||||
val fetchedAtUtcMs: Long = 0L,
|
val fetchedAtUtcMs: Long = 0L,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
val upload: AmSatUploadUiState = AmSatUploadUiState()
|
||||||
)
|
)
|
||||||
|
|
||||||
class SatStatusViewModel(
|
class SatStatusViewModel(
|
||||||
private val amSatRepo: IAmSatRepository
|
private val amSatRepo: IAmSatRepository,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
private val cachedStatusPage = amSatRepo.getCachedStatus()
|
||||||
|
private val _uiState = MutableStateFlow(cachedStatusPage?.toUiState() ?: SatStatusUiState(isLoading = true))
|
||||||
val uiState: StateFlow<SatStatusUiState> = _uiState
|
val uiState: StateFlow<SatStatusUiState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
fetch()
|
if (cachedStatusPage == null) fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetch() {
|
fun fetch() {
|
||||||
|
amSatRepo.getCachedStatus()?.let { page ->
|
||||||
|
applyStatusPage(page)
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
val page = amSatRepo.fetchStatus()
|
val page = amSatRepo.fetchStatus()
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
_uiState.update {
|
applyStatusPage(page)
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||||
@@ -66,16 +89,9 @@ class SatStatusViewModel(
|
|||||||
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val page = amSatRepo.fetchStatus()
|
val page = amSatRepo.fetchStatus(forceRefresh = true)
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
_uiState.update {
|
applyStatusPage(page)
|
||||||
it.copy(
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||||
}
|
}
|
||||||
@@ -85,11 +101,194 @@ class SatStatusViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyStatusPage(page: SatStatusPage) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
statuses = page.statuses,
|
||||||
|
reports = page.reports,
|
||||||
|
fetchedAtUtcMs = page.fetchedAtUtcMs,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleUploadPanel() {
|
||||||
|
val storedCallsign = settingsRepo.getAmSatCallsign()
|
||||||
|
val defaultGrid = defaultGridSquare()
|
||||||
|
_uiState.update { state ->
|
||||||
|
val upload = state.upload
|
||||||
|
val expanded = !upload.isExpanded
|
||||||
|
state.copy(
|
||||||
|
upload = if (expanded) {
|
||||||
|
upload.copy(
|
||||||
|
isExpanded = true,
|
||||||
|
callsign = upload.callsign.ifBlank { storedCallsign },
|
||||||
|
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
|
||||||
|
message = null,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collapseUploadPanel() {
|
||||||
|
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUploadReport(report: String) {
|
||||||
|
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUploadCallsign(callsign: String) {
|
||||||
|
val normalized = callsign.trim().uppercase(Locale.US)
|
||||||
|
settingsRepo.setAmSatCallsign(normalized)
|
||||||
|
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUploadGrid(grid: String) {
|
||||||
|
val normalized = grid.trim().uppercase(Locale.US)
|
||||||
|
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestSubmitConfirmation() {
|
||||||
|
val prepared = validateUpload(_uiState.value.upload) ?: return
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
isConfirmingSubmit = true,
|
||||||
|
message = null,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissSubmitConfirmation() {
|
||||||
|
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmSubmitReport(satelliteName: String) {
|
||||||
|
val upload = _uiState.value.upload
|
||||||
|
if (upload.isSubmitting) return
|
||||||
|
val prepared = validateUpload(upload) ?: return
|
||||||
|
|
||||||
|
settingsRepo.setAmSatCallsign(prepared.callsign)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
isSubmitting = true,
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = null,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = amSatRepo.submitReport(
|
||||||
|
AmSatReportSubmission(
|
||||||
|
name = satelliteName,
|
||||||
|
report = prepared.report,
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
reportedAtUtcMillis = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isSubmitting = false,
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
message = UPLOAD_MESSAGE_SUCCESS,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isSubmitting = false,
|
||||||
|
message = null,
|
||||||
|
error = result.message.ifBlank { "Upload failed" }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
|
||||||
|
val callsign = upload.callsign.trim().uppercase(Locale.US)
|
||||||
|
val grid = upload.gridSquare.trim().uppercase(Locale.US)
|
||||||
|
return when {
|
||||||
|
callsign.isBlank() -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = UPLOAD_ERROR_GRID_INVALID,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> PreparedUpload(
|
||||||
|
report = upload.selectedReport,
|
||||||
|
callsign = callsign,
|
||||||
|
grid = grid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultGridSquare(): String {
|
||||||
|
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
|
||||||
|
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SatStatusViewModel(container.amSatRepo)
|
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun SatStatusPage.toUiState() = SatStatusUiState(
|
||||||
|
statuses = statuses,
|
||||||
|
reports = reports,
|
||||||
|
fetchedAtUtcMs = fetchedAtUtcMs
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class PreparedUpload(
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val grid: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
org.gradle.tooling.parallel=true
|
|
||||||
org.gradle.workers.max=4
|
org.gradle.workers.max=4
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "446"
|
appVersionCode = "450"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.6"
|
appVersionName = "4.4.6-ba7opf.6"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Tue Aug 11 14:16:53 CEST 2026
|
#Sat Jun 27 12:51:12 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
|
distributionUrl=https\://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
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ include(
|
|||||||
)
|
)
|
||||||
include(
|
include(
|
||||||
":feature:map",
|
":feature:map",
|
||||||
|
":feature:mutual",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
|
|||||||
Reference in new issue
Block a user