Compare commits

..
Author SHA1 Message Date
atsunatsu 4cb11a8470 feat(satellites): fuzzy name search ignoring separators
The satellite search only matched exact continuous substrings, so names with
dashes, spaces or brackets (AO-7, ISS (ZARYA), FO-29) could not be found by
queries without those separators. The query is now normalized (lowercased,
non-alphanumeric separators stripped) and split into space-separated tokens;
every token must appear in the normalized satellite name. This makes 'ao7'
match 'AO-7 (AMSAT-OSCAR 7)' and 'iss zarya' match 'ISS (ZARYA)'. Numeric
queries still match catnum exactly.
2026-09-18 17:13:31 +08:00
Arty Bishop d471f02370 v4.4.7 - Data import overhaul, AMSAT reports, Map tweaks 2026-09-13 20:26:27 +01:00
Arty Bishop 9699d142fa Fixed RadarScreen padding, implemented basic Detekt setup 2026-09-13 17:29:38 +01:00
Arty Bishop 6f3e3351c9 Tweaked data source management, migrated DB and Settings 2026-09-13 15:19:23 +01:00
Arty Bishop d4a2fae0cd Updated dependencies and gradle plugin, minor fixes 2026-09-12 11:40:59 +01:00
dependabot[bot] d0bbb97f29 Bump actions/setup-java from 5 to 6 (#249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 08:40:23 +01:00
atsunatsuandatsunatsu c2fc5688b9 Added AMSAT satellite status reports submission (#248)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-03 08:39:34 +01:00
TianhengZhuang 796056a582 Extracted hardcoded UI strings to resources (en/zh) (#247) 2026-09-02 09:08:37 +01:00
atsunatsuandatsunatsu 9cac33dae7 Localized AMSAT status screen for Chinese (#245)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-02 09:05:45 +01:00
wty2019wty 082266e385 Overhauled data source management, added HTTP status (#242) 2026-08-21 14:11:30 +02:00
atsunatsuandatsunatsu a42a5f1f0d Tweaked sunrise/sunset calculations, added unit tests (#241)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-19 14:46:39 +02:00
Arty Bishop 7cdc2952dc v4.4.6 - AMSAT status page, fully customizable data sources 2026-08-13 09:47:26 +02:00
Arty Bishop bd51044690 Added custom frequency offset setting to network reporting 2026-08-12 20:17:26 +02:00
PingouinFerreux 1982c2d3dc Fixed broken star history chart in README (#240) 2026-08-12 18:22:53 +02:00
Arty Bishop 2f4e3f5802 Added the ability to fully customize data sources via import 2026-08-12 13:01:45 +02:00
Arty Bishop 24eebdef74 Consolidated app dialogs and tweaked bottom sheets 2026-08-11 14:14:14 +02:00
Arty Bishop 3f5b48f270 Integrated the AMSAT status page created by MCKero6423 2026-08-11 14:05:55 +02:00
atsunatsuandatsunatsu 060fa2dfcd Added remembering per-satellite doppler offset (#237)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-11 14:02:15 +02:00
atsunatsuandatsunatsu 8b5960282f Broaden linear transponder detection, logic fixes (#236)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-08 21:31:44 +02:00
mckero 10eb84690e Added AMSAT satellite status tracking page (#234) 2026-08-08 14:26:36 +02:00
bf25292bf8 Fixed recalculating Radar track on station position change (#235)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
Co-authored-by: wty2019wty <74123961+wty2019wty@users.noreply.github.com>
2026-08-08 14:10:55 +02:00
Arty Bishop 8fbfcb3712 v4.4.5 - Pass progress hotfix, swapped modes/filter dialogs 2026-08-05 13:14:50 +02:00
Arty Bishop 602b1553a2 v4.4.4 - Icom CAT, components, filters and sources tweaks 2026-08-04 20:51:22 +02:00
atsunatsuandatsunatsu bd0881fb4b Added linear transponder Doppler calculator (#232)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:45:12 +02:00
atsunatsuandatsunatsu a9dd3e6e55 Localized pass date and time formats for Chinese (#231)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:12:10 +02:00
Lukas 905995ab44 Added configurable Radar offset to the sensors output (#230) 2026-08-04 18:08:44 +02:00
Arty Bishop f9806d7f7f Replaced the types selection dialog with modes selection 2026-07-31 16:04:39 +02:00
Arty Bishop 2d3adfc6a6 Added small tweaks to Sources, Components and strings 2026-07-31 12:27:25 +02:00
107 changed files with 4379 additions and 3861 deletions

No files matched your search

+4
View File
@@ -4,3 +4,7 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "never"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v5 uses: actions/setup-java@v6
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
+1 -5
View File
@@ -1,9 +1,6 @@
# 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
@@ -18,7 +15,6 @@ 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
@@ -68,4 +64,4 @@ fastlane/readme.md
/app/release/output-metadata.json /app/release/output-metadata.json
/app/release/ /app/release/
/.kotlin/sessions/ /.kotlin/sessions/
dist/ .hermes/
+48 -49
View File
@@ -8,16 +8,15 @@ 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 TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial no tracking, no network required after initial data download.
data download.
## Architecture ## Architecture & Design
**MVI (Model-View-Intent)** with unidirectional data flow: **MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class → exposed via `StateFlow` from ViewModel - `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()` - `Action` sealed interface (named `<Feature>Action`) 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:**
@@ -34,9 +33,11 @@ data download.
| `feature:satellites` | Satellite list, filtering, selection | | `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences | | `feature:settings` | User preferences |
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other. **Feature isolation:**
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
## Build & Run ## Build & Platform
```shell ```shell
# Debug build # Debug build
@@ -50,54 +51,55 @@ data download.
``` ```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17 - **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/` - **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
## Key Libraries ## Tech Stack
- **Compose** (BOM 2026.05.01) + Material3 Adaptive - **Compose** (BOM 2026.05.01) + Material3 Adaptive
- **Navigation3** (type-safe, uses `@Serializable` NavKeys) - **Navigation3**: Type-safe navigation with `@Serializable` nav keys
- **Room** (KSP code generation) for local satellite/TLE storage - **Room** (KSP code generation) for local satellite/orbital storage
- **OkHttp** 5.x for TLE downloads - **OkHttp** 5.x for data downloads
- **OSMDroid** for map rendering - **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and data parsing - **Kotlin Serialization** for navigation args and parsing
- **Coroutines** + `StateFlow` for async/reactive patterns - **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
**TLE vs. OMM/CSV format:** Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility: - **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
- Downloads auto-detect format; both produce identical `OrbitalData` objects
- Existing code already supports transparent source transition without feature changes
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which ## Engineering Heuristics (Lazy = Efficient)
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
**Current implementation:** - Treat "lazy" as efficient, not careless: the best code is the code never written.
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly - First understand the task and trace the real flow end-to-end, then climb this ladder:
- TLE data is downloaded from configured sources and stored in Room database 1. Does this need to be built now? (YAGNI)
- When downloading satellite data, the app automatically detects format and parses accordingly 2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching 3. Does Kotlin/Java stdlib already solve it?
4. Does the Android/platform API already solve it?
5. Does an already-installed dependency solve it?
6. Can this be simpler (including one-liner simple)?
7. Only then: write the minimum code that works.
- Prefer deletion to addition, boring over clever, and the fewest touched files.
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
- When two options are similar in size, choose the edge-case-correct one.
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
**Migration path:** ## Bug-Fix Policy
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
to handle this transition without code changes — existing users can continue using TLE files while new sources
transition to OMM/CSV automatically.
## Code Style - Fix root cause, not just the reported symptom.
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
- Prefer **short, focused functions** — single responsibility, easy to read. - The smallest correct diff wins only after behavior is understood.
- **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
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
## Gotchas ## Gotchas
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully. - Orbital math lives in `core:domain/predict/` — 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 all shared Gradle configuration — edit there, not in individual modules. - `build-logic/convention/` contains 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
+30 -23
View File
@@ -1,38 +1,45 @@
# Look4Sat-BA7OPF # Look4Sat: Satellite tracker
[![Release](https://img.shields.io/github/v/release/atsunatsu/Look4Sat)](https://github.com/atsunatsu/Look4Sat/releases) [![Look4Sat CI](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。 [<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)
[<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
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情 <p float="left">
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星 <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本 <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变 <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放 <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换 </p>
## 上游仓库 ### Track satellite passes with ease!
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括: Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
You can search the entire database by NORAD Catalog Number or the satellite's name.
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪 Orbital positions and passes are calculated relative to your location.\
- SGP4/SDP4 轨道预测,10 天过境预报 To get reliable data make sure to set the station position via the app Settings.
- 极坐标雷达图、地面轨迹图
- 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.
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。 ## Main features:
* 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/#atsunatsu/Look4Sat&type=timeline&legend=top-left"> <a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
<picture> <picture>
<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: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&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" />
<img alt="Star History Chart" src="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" />
</picture> </picture>
</a> </a>
-20
View File
@@ -1,23 +1,3 @@
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")
}
}
}
-1
View File
@@ -25,7 +25,6 @@
<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,10 +17,7 @@
*/ */
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
@@ -38,37 +35,12 @@ 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,9 +51,6 @@ 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
@@ -64,16 +61,15 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
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
@@ -82,8 +78,6 @@ 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
@@ -94,16 +88,11 @@ 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 }
@@ -122,19 +111,13 @@ fun NavRoot(deeplink: String? = null) {
rememberViewModelStoreNavEntryDecorator() rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Passes> { entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
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, navigateToMap = navigateToMap) RadarDestination(navigateUp = navigateBack)
} }
} }
} }
@@ -142,36 +125,19 @@ fun NavRoot(deeplink: String? = null) {
} }
@Composable @Composable
fun MainScreen( private fun MainScreen(navigateToRadar: () -> Unit = {}) {
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 mutualViewModel: MutualViewModel = viewModel( val backStack = rememberNavBackStack(Screen.Passes)
viewModelStoreOwner = context as ViewModelStoreOwner, val currentKey = backStack.lastOrNull()
factory = MutualViewModel.factory(container) val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
) val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
CompositionLocalProvider( CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds( LocalElevationThresholds provides ElevationThresholds(
@@ -185,18 +151,22 @@ fun MainScreen(
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.Map -> screen is Screen.Passes is Screen.Status -> screen is Screen.Status
is Screen.AMSAT -> screen is Screen.AMSAT is Screen.Map -> screen is Screen.Map
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(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, icon = {
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 && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item if (isSelected) 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)
} }
@@ -221,9 +191,7 @@ fun MainScreen(
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 {
@@ -231,83 +199,73 @@ fun MainScreen(
SatellitesDestination(navigateUp = navigateBack) SatellitesDestination(navigateUp = navigateBack)
} }
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination( PassesDestination { catNum, aosTime ->
navigateToRadar = { catNum, aosTime -> container.satelliteRepo.selectPass(catNum, aosTime)
container.setMutualPassData(MutualPassData()) navigateToRadar()
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) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") TrackingBanner(
val alpha by infiniteTransition.animateFloat( state = trackingState,
initialValue = 1f, targetValue = 0.4f, onClick = {
animationSpec = infiniteRepeatable( val pass = trackingState.currentPass
animation = tween(1000, easing = LinearEasing), if (pass != null) {
repeatMode = RepeatMode.Reverse container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
), label = "pulseAlpha" navigateToRadar()
)
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 = stringResource(R.string.tracking_format, 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
)
}
}
@@ -32,7 +32,6 @@ 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"))
+40
View File
@@ -5,6 +5,46 @@ plugins {
alias(libs.plugins.google.ksp) apply false alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.other.detekt)
}
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
description = "Checks that source code satisfies detekt rules."
autoCorrect = false
}
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
description = "Applies code formatting rules to source code in-place."
autoCorrect = true
}
configure(listOf(detektCheck, detektApply)) {
configure {
group = "verification"
parallel = true
ignoreFailures = false
setSource(file(rootDir))
// Custom detekt config
config.setFrom("$projectDir/config/detekt/detekt.yml")
// Use default configuration on top of custom config
// (new detect rules will work out of the box after upgrading detekt version)
buildUponDefaultConfig = true
// Runs detekt for all files in the Gradle project and all subprojects without
// a need to configure detekt plugin in every subproject.
include("**/*.kt", "**/*.kts")
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
reports {
html.required.set(true)
xml.required.set(true)
}
}
dependencies {
detektPlugins(libs.other.detekt.formatting)
}
} }
tasks.register("clean", Delete::class.java) { tasks.register("clean", Delete::class.java) {
+808
View File
@@ -0,0 +1,808 @@
build:
maxIssues: 0
excludeCorrectable: false
weights:
# complexity: 2
# LongParameterList: 1
# style: 1
# comments: 1
config:
validation: true
warningsAsErrors: false
checkExhaustiveness: false
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
excludes: ''
processors:
active: true
exclude:
- 'DetektProgressListener'
# - 'KtFileCountProcessor'
# - 'PackageCountProcessor'
# - 'ClassCountProcessor'
# - 'FunctionCountProcessor'
# - 'PropertyCountProcessor'
# - 'ProjectComplexityProcessor'
# - 'ProjectCognitiveComplexityProcessor'
# - 'ProjectLLOCProcessor'
# - 'ProjectCLOCProcessor'
# - 'ProjectLOCProcessor'
# - 'ProjectSLOCProcessor'
# - 'LicenseHeaderLoaderExtension'
console-reports:
active: true
exclude:
- 'ProjectStatisticsReport'
- 'ComplexityReport'
- 'NotificationReport'
- 'FindingsReport'
- 'FileBasedFindingsReport'
# - 'LiteFindingsReport'
output-reports:
active: true
exclude:
# - 'TxtOutputReport'
# - 'XmlOutputReport'
- 'HtmlOutputReport'
# - 'MdOutputReport'
# - 'SarifOutputReport'
comments:
active: true
AbsentOrWrongFileLicense:
active: false
licenseTemplateFile: 'license.template'
licenseTemplateIsRegex: false
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
DeprecatedBlockTag:
active: false
EndOfSentenceFormat:
active: false
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
KDocReferencesNonPublicProperty:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
OutdatedDocumentation:
active: false
matchTypeParameters: true
matchDeclarationsOrder: true
allowParamOnConstructorProperties: false
UndocumentedPublicClass:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
searchInProtectedClass: false
UndocumentedPublicFunction:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchProtectedFunction: false
UndocumentedPublicProperty:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchProtectedProperty: false
complexity:
active: true
CognitiveComplexMethod:
active: false
threshold: 15
ComplexCondition:
active: true
threshold: 4
ComplexInterface:
active: false
threshold: 10
includeStaticDeclarations: false
includePrivateDeclarations: false
ignoreOverloaded: false
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
nestingFunctions:
- 'also'
- 'apply'
- 'forEach'
- 'isNotNull'
- 'ifNull'
- 'let'
- 'run'
- 'use'
- 'with'
# Composables describe UI trees declaratively and naturally accumulate more branches than
# regular functions. Exclude them here and rely on code review + decomposition instead.
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
LabeledExpression:
active: false
ignoredLabels: [ ]
LargeClass:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
threshold: 600
LongMethod:
active: true
# Composable UI functions are declarative trees; 60 lines is too strict for them.
# Screen/Slots files are excluded — keep them reasonable via code review.
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
threshold: 60
LongParameterList:
active: false
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: false
ignoreDataClasses: true
ignoreAnnotatedParameter: [ ]
MethodOverloading:
active: false
threshold: 6
NamedArguments:
active: false
threshold: 3
ignoreArgumentsMatchingNames: false
NestedBlockDepth:
active: true
threshold: 4
NestedScopeFunctions:
active: false
threshold: 1
functions:
- 'kotlin.apply'
- 'kotlin.run'
- 'kotlin.with'
- 'kotlin.let'
- 'kotlin.also'
ReplaceSafeCallChainWithRun:
active: false
StringLiteralDuplication:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
coroutines:
active: true
GlobalCoroutineUsage:
active: false
InjectDispatcher:
active: true
dispatcherNames:
- 'IO'
- 'Default'
- 'Unconfined'
RedundantSuspendModifier:
active: true
SleepInsteadOfDelay:
active: true
SuspendFunSwallowedCancellation:
active: false
SuspendFunWithCoroutineScopeReceiver:
active: false
SuspendFunWithFlowReturnType:
active: true
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: '_|(ignore|expected).*'
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverridden: true
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyTryBlock:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: true
methodNames:
- 'equals'
- 'finalize'
- 'hashCode'
- 'toString'
InstanceOfCheckForException:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
NotImplementedDeclaration:
active: false
ObjectExtendsThrowable:
active: false
PrintStackTrace:
active: true
RethrowCaughtException:
active: true
ReturnFromFinally:
active: true
ignoreLabeled: false
SwallowedException:
active: true
ignoredExceptionTypes:
- 'InterruptedException'
- 'MalformedURLException'
- 'NumberFormatException'
- 'ParseException'
allowedExceptionNameRegex: '_|(ignore|expected).*'
ThrowingExceptionFromFinally:
active: true
ThrowingExceptionInMain:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
exceptions:
- 'ArrayIndexOutOfBoundsException'
- 'Exception'
- 'IllegalArgumentException'
- 'IllegalMonitorStateException'
- 'IllegalStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
ThrowingNewInstanceOfSameException:
active: true
TooGenericExceptionCaught:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
- 'Exception'
- 'IllegalMonitorStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
allowedExceptionNameRegex: '_|(ignore|expected).*'
TooGenericExceptionThrown:
active: true
exceptionNames:
- 'Error'
- 'Exception'
- 'RuntimeException'
- 'Throwable'
formatting:
active: true
ArgumentListWrapping:
active: false
ParameterListWrapping:
active: false
NoWildcardImports:
active: false
MaximumLineLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
naming:
active: true
BooleanPropertyNaming:
active: false
allowedPattern: '^(is|has|are)'
ClassNaming:
active: true
classPattern: '[A-Z][a-zA-Z0-9_]*'
ConstructorParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
forbiddenName: [ ]
FunctionMaxLength:
active: false
maximumFunctionNameLength: 30
FunctionMinLength:
active: false
minimumFunctionNameLength: 3
FunctionNaming:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
excludeClassPattern: '$^'
FunctionParameterNaming:
active: true
parameterPattern: '[a-zA-Z][A-Za-z0-9]*'
excludeClassPattern: '$^'
InvalidPackageDeclaration:
active: true
rootPackage: ''
requireRootInDeclaration: false
LambdaParameterNaming:
active: false
parameterPattern: '[a-z][A-Za-z0-9]*|_'
MatchingDeclarationName:
active: false
mustBeFirst: false
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
NoNameShadowing:
active: true
NonBooleanPropertyPrefixedWithIs:
active: false
ObjectPropertyNaming:
active: true
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
TopLevelPropertyNaming:
active: true
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: false
maximumVariableNameLength: 64
VariableMinLength:
active: false
minimumVariableNameLength: 1
VariableNaming:
active: true
variablePattern: '[a-zA-Z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-zA-Z][A-Za-z0-9]*'
excludeClassPattern: '$^'
performance:
active: true
ArrayPrimitive:
active: true
CouldBeSequence:
active: false
threshold: 3
ForEachOnRange:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
SpreadOperator:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
UnnecessaryPartOfBinaryExpression:
active: false
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
AvoidReferentialEquality:
active: true
forbiddenTypePatterns:
- 'kotlin.String'
CastNullableToNonNullableType:
active: false
CastToNullableType:
active: false
Deprecation:
active: false
DontDowncastCollectionTypes:
active: false
DoubleMutabilityForCollection:
active: true
mutableTypes:
- 'kotlin.collections.MutableList'
- 'kotlin.collections.MutableMap'
- 'kotlin.collections.MutableSet'
- 'java.util.ArrayList'
- 'java.util.LinkedHashSet'
- 'java.util.HashSet'
- 'java.util.LinkedHashMap'
- 'java.util.HashMap'
ElseCaseInsteadOfExhaustiveWhen:
active: false
ignoredSubjectTypes: [ ]
EqualsAlwaysReturnsTrueOrFalse:
active: true
EqualsWithHashCodeExist:
active: true
ExitOutsideMain:
active: false
ExplicitGarbageCollectionCall:
active: true
HasPlatformType:
active: true
IgnoredReturnValue:
active: true
restrictToConfig: true
returnValueAnnotations:
- 'CheckResult'
- '*.CheckResult'
- 'CheckReturnValue'
- '*.CheckReturnValue'
ignoreReturnValueAnnotations:
- 'CanIgnoreReturnValue'
- '*.CanIgnoreReturnValue'
returnValueTypes:
- 'kotlin.sequences.Sequence'
- 'kotlinx.coroutines.flow.*Flow'
- 'java.util.stream.*Stream'
ignoreFunctionCall: [ ]
ImplicitDefaultLocale:
active: true
ImplicitUnitReturnType:
active: false
allowExplicitReturnType: true
InvalidRange:
active: true
IteratorHasNextCallsNextMethod:
active: true
IteratorNotThrowingNoSuchElementException:
active: true
LateinitUsage:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
ignoreOnClassesPattern: ''
MapGetWithNotNullAssertionOperator:
active: true
MissingPackageDeclaration:
active: false
excludes: [ '**/*.kts' ]
NullCheckOnMutableProperty:
active: false
NullableToStringCall:
active: false
PropertyUsedBeforeDeclaration:
active: false
UnconditionalJumpStatementInLoop:
active: false
UnnecessaryNotNullCheck:
active: false
UnnecessaryNotNullOperator:
active: true
UnnecessarySafeCall:
active: true
UnreachableCatchBlock:
active: true
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
UnsafeCast:
active: true
UnusedUnaryOperator:
active: true
UselessPostfixExpression:
active: true
WrongEqualsTypeParameter:
active: true
style:
active: true
AlsoCouldBeApply:
active: false
BracesOnIfStatements:
active: false
singleLine: 'never'
multiLine: 'always'
BracesOnWhenStatements:
active: false
singleLine: 'necessary'
multiLine: 'consistent'
CanBeNonNullable:
active: false
CascadingCallWrapping:
active: false
includeElvis: true
ClassOrdering:
active: false
CollapsibleIfStatements:
active: false
DataClassContainsFunctions:
active: false
conversionFunctionPrefix:
- 'to'
allowOperators: false
DataClassShouldBeImmutable:
active: false
DestructuringDeclarationWithTooManyEntries:
active: true
maxDestructuringEntries: 3
DoubleNegativeLambda:
active: false
negativeFunctions:
- reason: 'Use `takeIf` instead.'
value: 'takeUnless'
- reason: 'Use `all` instead.'
value: 'none'
negativeFunctionNameParts:
- 'not'
- 'non'
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: false
ExplicitCollectionElementAccessMethod:
active: false
ExplicitItLambdaParameter:
active: true
ExpressionBodySyntax:
active: false
includeLineWrapping: false
ForbiddenAnnotation:
active: false
annotations:
- reason: 'it is a java annotation. Use `Suppress` instead.'
value: 'java.lang.SuppressWarnings'
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
value: 'java.lang.Deprecated'
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
value: 'java.lang.annotation.Documented'
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
value: 'java.lang.annotation.Target'
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
value: 'java.lang.annotation.Retention'
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
value: 'java.lang.annotation.Repeatable'
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
value: 'java.lang.annotation.Inherited'
ForbiddenComment:
active: true
comments:
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
value: 'FIXME:'
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
value: 'STOPSHIP:'
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
value: 'TODO:'
allowedPatterns: ''
ForbiddenImport:
active: false
imports: [ ]
forbiddenPatterns: ''
ForbiddenMethodCall:
active: false
methods:
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.print'
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.println'
ForbiddenSuppress:
active: false
rules: [ ]
ForbiddenVoid:
active: true
ignoreOverridden: false
ignoreUsageInGenerics: false
FunctionOnlyReturningConstant:
active: true
ignoreOverridableFunction: true
ignoreActualFunction: true
excludedFunctions: [ ]
LoopWithTooManyJumpStatements:
active: true
maxJumpCount: 1
MagicNumber:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: false
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
MandatoryBracesLoops:
active: false
MaxChainedCallsOnSameLine:
active: false
maxChainedCalls: 5
MaxLineLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: true
excludeRawStrings: true
MayBeConst:
active: true
ModifierOrder:
active: true
MultilineLambdaItParameter:
active: false
MultilineRawStringIndentation:
active: false
indentSize: 4
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
NestedClassesVisibility:
active: true
NewLineAtEndOfFile:
active: true
NoTabs:
active: false
NullableBooleanCheck:
active: false
ObjectLiteralToLambda:
active: true
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: false
OptionalWhenBraces:
active: false
PreferToOverPairSyntax:
active: false
ProtectedMemberInFinalClass:
active: true
RedundantExplicitType:
active: false
RedundantHigherOrderMapUsage:
active: true
RedundantVisibilityModifierRule:
active: false
ReturnCount:
active: true
max: 3
excludedFunctions:
- 'equals'
excludeLabeled: false
excludeReturnFromLambda: true
# Guard clauses (early returns for preconditions) are idiomatic Kotlin and should not
# count toward the return limit. E.g. `if (x == null) return null` at function top.
excludeGuardClauses: true
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: true
SpacingBetweenPackageAndImports:
active: false
StringShouldBeRawString:
active: false
maxEscapedCharacterCount: 2
ignoredCharacters: [ ]
ThrowsCount:
active: true
max: 2
excludeGuardClauses: false
TrailingWhitespace:
active: false
TrimMultilineRawString:
active: false
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
UnderscoresInNumericLiterals:
active: false
acceptableLength: 4
allowNonStandardGrouping: false
UnnecessaryAbstractClass:
active: true
UnnecessaryAnnotationUseSiteTarget:
active: false
UnnecessaryApply:
active: true
UnnecessaryBackticks:
active: false
UnnecessaryBracesAroundTrailingLambda:
active: false
UnnecessaryFilter:
active: true
UnnecessaryInheritance:
active: true
UnnecessaryInnerClass:
active: false
UnnecessaryLet:
active: false
UnnecessaryParentheses:
active: false
allowForUnclearPrecedence: false
UntilInsteadOfRangeTo:
active: false
UnusedImports:
active: false
UnusedParameter:
active: true
allowedNames: 'ignored|expected'
UnusedPrivateClass:
active: true
UnusedPrivateMember:
active: true
allowedNames: '[A-Z][a-zA-Z0-9]*Preview'
UnusedPrivateProperty:
active: true
allowedNames: '_|ignored|expected|serialVersionUID'
UseAnyOrNoneInsteadOfFind:
active: true
UseArrayLiteralsInAnnotations:
active: true
UseCheckNotNull:
active: true
UseCheckOrError:
active: true
UseDataClass:
active: false
allowVars: false
UseEmptyCounterpart:
active: false
UseIfEmptyOrIfBlank:
active: false
UseIfInsteadOfWhen:
active: false
ignoreWhenContainingVariableDeclaration: false
UseIsNullOrEmpty:
active: true
UseLet:
active: false
UseOrEmpty:
active: true
UseRequire:
active: true
UseRequireNotNull:
active: true
UseSumOfInsteadOfFlatMapSize:
active: false
UselessCallOnNotNull:
active: true
UtilityClassWithPublicConstructor:
active: true
VarCouldBeVal:
active: true
ignoreLateinitVar: false
WildcardImport:
active: false
excludeImports:
- 'java.util.*'
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@@ -39,13 +40,30 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)") @Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry> suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
@Query("SELECT catnum, epoch FROM entries")
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
@Query("SELECT catnum, name FROM entries")
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
suspend fun renameEntry(catnum: Int, name: String)
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
suspend fun deleteEntriesWithIds(ids: List<Int>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntries(entries: List<SatEntry>) suspend fun insertEntries(entries: List<SatEntry>)
@Query("DELETE FROM entries") @Query("DELETE FROM entries")
suspend fun deleteEntries() suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)") @Query(
"""
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
"""
)
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios") @Query("SELECT COUNT(*) FROM radios")
@@ -60,4 +78,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios") @Query("DELETE FROM radios")
suspend fun deleteRadios() suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
} }
@@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false) const val DATABASE_NAME = "Look4SatDBv400"
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() { abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao abstract fun look4SatDao(): Look4SatDao
} }
//val MIGRATION_1_2 = object : Migration(1, 2) { /** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
// override fun migrate(database: SupportSQLiteDatabase) { * transceivers that were imported from a file, so that remote updates leave them alone. */
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))") val MIGRATION_1_2 = object : Migration(1, 2) {
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries") override fun migrate(db: SupportSQLiteDatabase) {
// database.execSQL("DROP TABLE entries") db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries") db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
// } }
//} }
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double, val argper: Double,
val meanan: Double, val meanan: Double,
val catnum: Int, val catnum: Int,
val bstar: Double val bstar: Double,
val ndot: Double = 0.0
) )
@@ -32,5 +32,7 @@ data class SatRadio(
val uplinkHigh: Long?, val uplinkHigh: Long?,
val uplinkMode: String?, val uplinkMode: String?,
val isInverted: Boolean, val isInverted: Boolean,
val catnum: Int? val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
) )
@@ -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) { resetRotatorConnection() } write(rotatorSocket, command) { rotatorConnected = false }
} }
} }
@@ -154,7 +154,5 @@ class NetworkReporter(
} }
private fun String.unescapeControlChars(): String = private fun String.unescapeControlChars(): String =
replace("""\r""", 13.toChar().toString()) replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
.replace("""\n""", 10.toChar().toString())
.replace("""\t""", 9.toChar().toString())
} }
@@ -22,7 +22,10 @@ import android.content.Context
import android.hardware.SensorManager import android.hardware.SensorManager
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.location.LocationManager import android.location.LocationManager
import androidx.core.content.pm.PackageInfoCompat
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -51,7 +54,6 @@ 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
@@ -63,9 +65,6 @@ 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 {
@@ -78,19 +77,12 @@ 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, appScope) } override val amSatRepo by lazy { AmSatRepository(remoteSource) }
override val radioTrackingService: IRadioTrackingService by lazy { override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
} }
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context) override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context) override fun provideShowToast(): IShowToast = ShowToast(context)
@@ -150,21 +142,34 @@ 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)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo) return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
} }
private fun provideLocalSource(): ILocalSource { private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400") val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
val database = builder.fallbackToDestructiveMigration(false).build() val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
return LocalSource(database.look4SatDao()) return LocalSource(database.look4SatDao())
} }
private fun provideRemoteSource(): IRemoteSource { private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build()) val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
// Data providers ask clients to identify themselves, so that they can reach out to the
// developer instead of silently blocking every user of the app behind a misbehaving request
val client = OkHttpClient.Builder().addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
}.build()
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
} }
private fun provideSatelliteRepo(): ISatelliteRepo { private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -179,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val appPrefsFileName = "${context.packageName}_preferences" val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE) val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4" val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return SettingsRepo(manager, appPreferences, appVersionName) val appVersionName = packageInfo.versionName ?: "4.0.4"
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
} }
} }
@@ -9,13 +9,7 @@ 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
@@ -35,85 +29,27 @@ 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( class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
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 fun getCachedStatus(): SatStatusPage? = statusCache override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
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 null val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext 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 null val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson) val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson) val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return null if (names.isEmpty() && reports.isEmpty()) return@withContext 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) }
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page -> SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
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) { override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
@@ -196,14 +132,13 @@ class AmSatRepository(
} }
} }
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */ /** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> { 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 utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 3).map { d -> val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000 utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" formatDayLabel(utc)
} }
return names.map { name -> return names.map { name ->
val slots = (0 until 36).map { slotIdx -> val slots = (0 until 36).map { slotIdx ->
@@ -228,6 +163,15 @@ class AmSatRepository(
} }
} }
private fun formatDayLabel(calendar: Calendar): String {
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
} else {
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
}
}
private fun toSatReport(r: ApiReport): SatReport { private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000 cal.timeInMillis = r.reportedTimeUtcSec * 1000
@@ -23,7 +23,6 @@ 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
@@ -40,14 +39,13 @@ 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 = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
localSource.insertEntries(entries) insertFresherEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum }) // report what the file contained: a valid file holding only stale data is not an error
importedCount = entries.size importedCount = entries.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -58,7 +56,7 @@ class DatabaseRepo(
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream)) val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers) localSource.insertRadios(transceivers, isCustom = true)
importedCount = transceivers.size importedCount = transceivers.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -67,32 +65,39 @@ class DatabaseRepo(
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val settings = settingsRepo.dataSourcesSettings.value val settings = settingsRepo.dataSourcesSettings.value
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct() fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct() val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
val builtinTypesByUrl = Sources.satelliteDataUrls val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
.filterValues { it.isNotBlank() } // launch all network requests concurrently, keeping the raw url as key for status reporting
.mapValues { normalizeUrl(it.value) } val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
.entries val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
.associate { (type, url) -> url to type } val tleResults = tleJobs.awaitAll()
val importedTypeIds = mutableMapOf<String, MutableList<Int>>() val radioResults = radioJobs.awaitAll()
// launch all network requests concurrently // report the HTTP status code of every source (200, 404, ...)
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } settingsRepo.updateDataSourcesStatus(
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } (tleResults + radioResults).associate { (url, result) -> url to result.code }
// parse fetched data concurrently and associate known built-in URLs with existing type filters. )
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> // parse fetched data concurrently, merging satellites by freshness instead of source order
val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() val parsedPerSource = tleResults.map { (url, result) ->
val type = builtinTypesByUrl[url] ?: customSourceType result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum }) }
entries val importedRadios = radioResults.flatMap { (url, result) ->
}.distinctBy { it.catnum } result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid } }.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database // insert parsed data into the database
localSource.insertEntries(importedEntries) insertFresherEntries(mergeEntries(parsedPerSource))
localSource.insertRadios(importedRadios) // transceivers are a full snapshot: sources publish active entries only, so a retired
setUpdateSuccessful(System.currentTimeMillis()) // transceiver simply disappears from the feed and has to be dropped locally as well.
// Imported ones are kept: no source can refresh them, so nothing would bring them back
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios, isCustom = false)
}
// keep the previous timestamp when every source failed, so the next launch retries
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
if (hasFetchedData) pruneStaleEntries()
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
} }
override suspend fun clearAllData() = withContext(dispatcher) { override suspend fun clearAllData() = withContext(dispatcher) {
@@ -101,9 +106,6 @@ 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 {
@@ -131,6 +133,62 @@ class DatabaseRepo(
line.count { it == ',' } >= 4 line.count { it == ',' } >= 4
} }
/**
* Merges the data of every source: orbital elements always come from the set with the newest
* epoch, while the name comes from the first source that provides it. Source order is thus a
* naming preference only, which also keeps names stable when sources leapfrog each other.
*/
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
val preferredNames = mutableMapOf<Int, String>()
val freshestEntries = mutableMapOf<Int, OrbitalData>()
sourceEntries.forEach { entries ->
entries.forEach { entry ->
val name = entry.name.trim()
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
val current = freshestEntries[entry.catnum]
if (current == null || entry.epochDaynum > current.epochDaynum) {
freshestEntries[entry.catnum] = entry
}
}
}
return freshestEntries.values.map { entry ->
val name = preferredNames[entry.catnum]
if (name == null || name == entry.name) entry else entry.copy(name = name)
}
}
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
val storedEpochs = localSource.getEntriesEpochs()
val (fresherEntries, staleEntries) = entries.partition { entry ->
val storedEpoch = storedEpochs[entry.catnum]
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
}
localSource.insertEntries(fresherEntries)
// a reordered source list has to rename satellites right away, even the ones holding
// elements that are newer than the ones just parsed
if (staleEntries.isNotEmpty()) {
val storedNames = localSource.getEntriesNames()
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
if (renamedEntries.isNotEmpty()) {
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
}
}
}
/**
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
* disappeared from every catalog. Manually imported data ages out the same way, and every
* source republishes active satellites well within that window.
*/
private suspend fun pruneStaleEntries() {
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
val staleIds = localSource.getEntriesEpochs()
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
.keys.toList()
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
}
private suspend fun setUpdateSuccessful(timestamp: Long) { private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState( settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -38,6 +38,7 @@ 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,
@@ -78,7 +79,7 @@ class SatelliteRepo(
aosStartMinute = settings.aosStartMinute, aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute, aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow, invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes modes = settingsRepo.selectedSatModes.value
) )
} }
} }
@@ -163,7 +164,7 @@ class SatelliteRepo(
} }
} }
newPasses.sortBy { it.aosTime } newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX delay(1000.milliseconds) // Simulate loading time for better UX
_passes.update { newPasses } _passes.update { newPasses }
} }
_isCalculating.value = false _isCalculating.value = false
@@ -181,7 +182,7 @@ class SatelliteRepo(
val inRange = if (aosStartMinute <= aosEndMinute) { val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute aosMinute in aosStartMinute..aosEndMinute
} else { } else {
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute aosMinute !in (aosEndMinute + 1)..<aosStartMinute
} }
return if (invertAosTimeWindow) !inRange else inRange return if (invertAosTimeWindow) !inRange else inRange
} }
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo { ) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList()) private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("") private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively. // Resolve sat IDs once when modes change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List. // The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> -> // Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
val catnumSet: Set<Int>? = if (types.isEmpty()) { private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
null // null = no filtering null // null = no filtering
} else { } else {
val ids = settingsRepo.getSatelliteTypesIds(types) val ids = localSource.getIdsWithModes(list)
if (ids.isEmpty()) null else ids.toHashSet() if (ids.isEmpty()) null else ids.toHashSet()
} }
currentItems.map { items -> currentItems.map { items ->
@@ -56,14 +56,18 @@ class SelectionRepo(
} }
private val itemsWithQuery = currentQuery.flatMapLatest { query -> private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) } itemsWithModes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
} }
override fun getCurrentTypes() = currentTypes.value override fun getCurrentModes() = settingsRepo.selectedSatModes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { override fun getModesList() = Sources.satelliteModes
removeAt(0)
}
override suspend fun getEntriesFlow() = withContext(dispatcher) { override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet() val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -73,9 +77,8 @@ class SelectionRepo(
return@withContext itemsWithQuery return@withContext itemsWithQuery
} }
override suspend fun setTypes(types: List<String>) { override suspend fun setModes(modes: List<String>) {
currentTypes.value = types settingsRepo.setSelectedSatModes(modes)
settingsRepo.setSelectedTypes(types)
} }
override suspend fun setQuery(query: String) { override suspend fun setQuery(query: String) {
@@ -111,12 +114,29 @@ class SelectionRepo(
/** /**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow, * Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item. * and lowercases the query once up front instead of per-item.
*
* Fuzzy search: the query is split into space-separated tokens and every
* token must appear in the satellite name after both sides are normalized
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
* previously failed whenever the name contained a separator the query lacked.
*/ */
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> { private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items if (query.isBlank()) return items
val catnum = query.toIntOrNull() val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum } if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase() val tokens = query.split(' ')
return items.filter { it.name.lowercase().contains(lowerQuery) } .map { normalizeForSearch(it) }
.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return items
return items.filter { item ->
val normalizedName = normalizeForSearch(item.name)
tokens.all { normalizedName.contains(it) }
}
} }
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
} }
@@ -29,8 +29,10 @@ 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
@@ -39,13 +41,12 @@ 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 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,
private val preferences: SharedPreferences, private val preferences: SharedPreferences,
override val appVersionName: String override val appVersionName: String,
override val appVersionCode: Long,
) : ISettingsRepo, LocationListenerCompat { ) : ISettingsRepo, LocationListenerCompat {
private val keyBluetoothRotatorAddress = "bluetoothAddress" private val keyBluetoothRotatorAddress = "bluetoothAddress"
@@ -73,8 +74,7 @@ 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 keySelectedTypes = "selectedTypes" private val keySelectedSatModes = "selectedSatModes"
private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate" private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors" private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep" private val keyStateOfSweep = "stateOfSweep"
@@ -88,27 +88,38 @@ class SettingsRepo(
private val keyStationTimestamp = "stationTimestamp" private val keyStationTimestamp = "stationTimestamp"
private val keyUpdateTimestamp = "updateTimestamp" private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
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 keyUseCustomTle = "useCustomTle" private val keyRadarCompassOffset = "radarCompassOffset"
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keyTleUrl = "tleUrl" private val keySatellitesUrls = "satellitesUrls"
private val keyTransceiversUrl = "transceiversUrl"
private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls" private val keyTransceiversUrls = "transceiversUrls"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration" private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
private val keySettingsVersion = "settingsVersion"
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" init {
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
// on the first launch instead of waiting up to 48 hours for the automatic update.
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
preferences.edit {
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
.forEach { key -> remove(key) }
putInt(keySettingsVersion, appVersionCode.toInt())
}
}
}
//region # Satellites selection settings //region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds()) private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes()) private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
override fun setSelectedIds(ids: List<Int>) { override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma) val selectionString = ids.joinToString(separatorComma)
@@ -116,10 +127,10 @@ class SettingsRepo(
_satelliteSelection.value = ids _satelliteSelection.value = ids
} }
override fun setSelectedTypes(types: List<String>) { override fun setSelectedSatModes(modes: List<String>) {
val typesString = types.joinToString(separatorComma) val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) } preferences.edit { putString(keySelectedSatModes, modesString) }
_typesSelection.value = types _satelliteModeSelection.value = modes
} }
private fun getSelectedIds(): List<Int> { private fun getSelectedIds(): List<Int> {
@@ -128,10 +139,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() } return selectionString.split(separatorComma).map { it.toInt() }
} }
private fun getSelectedTypes(): List<String> { private fun getSelectedSatModes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, "Amateur") val modesString = preferences.getString(keySelectedSatModes, null)
if (typesString.isNullOrEmpty()) return emptyList() if (modesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma) return modesString.split(separatorComma).sorted()
} }
//endregion //endregion
@@ -146,7 +157,6 @@ 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
} }
@@ -157,16 +167,13 @@ 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
@@ -246,25 +253,6 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState()) private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
val typeString = preferences.getString("type$type", null)
val typeIds = if (typeString.isNullOrBlank()) {
emptyList()
} else {
typeString.split(separatorComma).map { it.toInt() }
}
idsSet.addAll(typeIds)
}
return idsSet.toList()
}
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
if (type == "All") return
val typesString = ids.joinToString(separatorComma)
preferences.edit { putString("type$type", typesString) }
}
override fun updateDatabaseState(state: DatabaseState) = preferences.edit { override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
putInt(keyNumberOfSatellites, state.numberOfSatellites) putInt(keyNumberOfSatellites, state.numberOfSatellites)
@@ -374,6 +362,8 @@ 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
} }
@@ -390,7 +380,9 @@ 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
@@ -399,71 +391,72 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun updateDataSourcesSettings(settings: DataSourcesSettings) { override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit { // Normalize the enabled lists so they are positionally aligned with the URL lists.
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl)) // Missing entries default to enabled (true), keeping the persisted "one flag per URL"
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl)) // invariant intact even when a default empty list is used to construct the model.
putBoolean(keySatnogsTleSourceMigration, true) val normalized = settings.copy(
} satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
_dataSourcesSettings.value = settings transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
satelliteUrls = getDataSourceUrls(
key = keySatelliteUrls,
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTle,
legacyUrlKey = keyTleUrl
).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 { preferences.edit {
putString(keySatelliteUrls, migrated.joinToString(separatorUrl)) putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putBoolean(keySatnogsTleSourceMigration, true) putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
} }
return migrated _dataSourcesSettings.value = normalized
} }
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) } private fun getDataSourcesSettings(): DataSourcesSettings {
val (satUrls, satEnabled) = parseSources(
preferences.getString(keySatellitesUrls, null),
preferences.getString(keySatellitesUrlsEnabled, null),
Sources.satelliteDataUrls
)
val (txUrls, txEnabled) = parseSources(
preferences.getString(keyTransceiversUrls, null),
preferences.getString(keyTransceiversUrlsEnabled, null),
Sources.transceiversDataUrls
)
return DataSourcesSettings(
satelliteUrls = satUrls,
transceiversUrls = txUrls,
satelliteEnabled = satEnabled,
transceiversEnabled = txEnabled
)
}
private fun isSameSourceUrl(first: String, second: String): Boolean = private fun parseSources(
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true) storedUrls: String?,
storedEnabled: String?,
defaults: List<String>
): Pair<List<String>, List<Boolean>> {
if (storedUrls == null) return defaults to defaults.map { true }
val urls = storedUrls.split(separatorUrl)
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
val filteredUrls = mutableListOf<String>()
val filteredFlags = mutableListOf<Boolean>()
urls.forEachIndexed { index, url ->
if (url.isNotBlank()) {
filteredUrls.add(url)
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
}
}
return filteredUrls to filteredFlags
}
private fun normalizeSourceUrl(url: String): String { private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
val trimmed = url.trim() if (flags.size >= urls.size) return flags.take(urls.size)
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed" return flags + List(urls.size - flags.size) { true }
}
//endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
} }
//endregion //endregion
@@ -504,29 +497,16 @@ 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, "{}") ?: "{}"
val stored = try { return 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) {
@@ -536,14 +516,10 @@ 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) {
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}""" """{"$catnum": "$offset"}"""
}
preferences.edit {
putString(keySatelliteOffsets, updated)
remove("$keyLegacySatelliteOffsetPrefix$catnum")
} }
preferences.edit { putString(keySatelliteOffsets, updated) }
} }
//endregion
//region # AMSAT status report settings //region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign" private val keyAmSatCallsign = "amSatCallsign"
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return selectedOrbitalObjects return selectedOrbitalObjects
} }
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
override suspend fun renameEntries(names: Map<Int, String>) =
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity()) override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
override suspend fun deleteEntries() = look4SatDao.deleteEntries() override suspend fun deleteEntries() = look4SatDao.deleteEntries()
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData( private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
) )
private fun OrbitalData.toEntity() = FrameworkEntry( private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
) )
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() } private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
@@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios() return look4SatDao.getRadiosWithId(id).toDomainRadios()
} }
override suspend fun insertRadios(radios: List<SatRadio>) { override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.insertRadios(radios.toFrameworkRadios()) look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
} }
override suspend fun deleteRadios() = look4SatDao.deleteRadios() override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio( override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
) )
private fun FrameworkRadio.toDomain() = DomainRadio( private fun FrameworkRadio.toDomain() = DomainRadio(
@@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
) )
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() } private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
//endregion //endregion
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver import android.content.ContentResolver
import androidx.core.net.toUri import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -44,20 +45,26 @@ class RemoteSource(
} }
} }
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
try { try {
val networkRequest = Request.Builder().url(url).build() val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute() val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val code = response.code
response.close() response.close()
return@withContext null return@withContext NetworkResult(code, 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
// Closing the stream returns the connection to OkHttp's pool. // That returns the connection to OkHttp's pool
response.body.byteStream().buffered() val body = response.body
if (body == null) {
response.close()
return@withContext NetworkResult(response.code, null)
}
NetworkResult(response.code, body.byteStream().buffered())
} catch (exception: Exception) { } catch (exception: Exception) {
println("RemoteSource network stream exception: $exception") println("RemoteSource network stream exception: $exception")
null NetworkResult(NetworkResult.CONNECTION_ERROR, null)
} }
} }
@@ -1,106 +0,0 @@
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
}
@@ -31,7 +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.source.NetworkResult
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
@@ -42,6 +42,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest { class DatabaseRepoTest {
@@ -49,6 +50,10 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher() private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher) private val dataParser = DataParser(dispatcher)
// fixtures carry a current epoch: stale entries are pruned on every remote update
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
@Test @Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) { fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites" val uri = "content://look4sat/import/satellites"
@@ -63,7 +68,6 @@ class DatabaseRepoTest {
assertEquals(1, localSource.insertedEntries.size) assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum) assertEquals(25544, localSource.insertedEntries.first().catnum)
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0) assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
} }
@@ -101,19 +105,20 @@ 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 @Test
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) { fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS") val primaryUrl = "https://example.com/primary.txt"
val secondaryUrl = "https://example.com/secondary.txt"
val localSource = FakeLocalSource() val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply { val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { jamxTleStream() } networkStreams[primaryUrl] = { validTleStream() }
networkStreams[secondaryUrl] = { fresherTleStream() }
} }
val settingsRepo = FakeSettingsRepo( val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings( dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl), satelliteUrls = listOf(primaryUrl, secondaryUrl),
transceiversUrls = emptyList() transceiversUrls = emptyList()
) )
) )
@@ -121,35 +126,167 @@ class DatabaseRepoTest {
repository.updateFromRemote() repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 }) val entry = localSource.insertedEntries.single { it.catnum == 25544 }
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"]) assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
val freshUri = "content://look4sat/import/fresh"
val staleUri = "content://look4sat/import/stale"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[freshUri] = { fresherTleStream() }
fileStreams[staleUri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
assertEquals(1, repository.updateTLEFromFile(freshUri))
assertEquals(1, repository.updateTLEFromFile(staleUri))
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
}
@Test
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
repository.updateFromRemote()
assertEquals(2, localSource.insertedRadios.size)
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
repository.updateFromRemote()
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
}
@Test
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
val preferredUrl = "https://example.com/preferred.txt"
val freshUrl = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[freshUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(freshUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals("ISS", localSource.insertedEntries.single().name)
// the user moves a source with older elements, but a better name, to the top of the list
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
satelliteUrls = listOf(preferredUrl, freshUrl),
transceiversUrls = emptyList()
)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single()
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
val failingUrl = "https://example.com/offline.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(failingUrl),
transceiversUrls = emptyList()
)
)
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// the update timestamp stays put so the data is not presented as fresh, while the recorded
// attempt keeps the next app launch from hammering a source that is already failing
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
}
@Test
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
val radioUri = "content://imported.json"
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[radioUri] = { customRadioStream() }
networkStreams[radioUrl] = { singleRadioStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTransceiversFromFile(radioUri)
repository.updateFromRemote()
// the remote snapshot replaces what the sources publish, the imported entry stays put
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
} }
private fun validCsvStream(): InputStream = """ private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """ private fun validTleStream(): InputStream = """
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A $todayEpoch.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 = """ private fun fresherTleStream(): InputStream = """
JAMX-0825b ISS
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013 1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun twoRadiosStream(): InputStream = """
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream()
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
private fun customRadioStream(): InputStream = """
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
} }
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
private class FakeRemoteSource : IRemoteSource { private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() override suspend fun getNetworkStream(url: String): NetworkResult {
val stream = networkStreams[url]?.invoke()
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
}
override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatCatalog(): String? = null
@@ -160,7 +297,8 @@ private class FakeRemoteSource : IRemoteSource {
private class FakeLocalSource : ILocalSource { private class FakeLocalSource : ILocalSource {
val insertedEntries = mutableListOf<OrbitalData>() val insertedEntries = mutableListOf<OrbitalData>()
private val insertedRadios = mutableListOf<SatRadio>() val insertedRadios = mutableListOf<SatRadio>()
private val customRadios = mutableListOf<String>()
override suspend fun getEntriesTotal(): Int = insertedEntries.size override suspend fun getEntriesTotal(): Int = insertedEntries.size
@@ -168,6 +306,21 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> =
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
override suspend fun getEntriesNames(): Map<Int, String> =
insertedEntries.associate { entry -> entry.catnum to entry.name }
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
}
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
insertedEntries.removeAll { entry -> entry.catnum in ids }
}
override suspend fun insertEntries(entries: List<OrbitalData>) { override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries insertedEntries += entries
} }
@@ -182,25 +335,34 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) { override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
insertedRadios += radios insertedRadios += radios
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
} }
override suspend fun deleteRadios() { override suspend fun deleteRadios() {
insertedRadios.clear() insertedRadios.clear()
customRadios.clear()
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
} }
} }
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo { private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
override val appVersionName: String = "test" override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList()) override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList()) override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow( override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList()) PassesSettings(hoursAhead = 24, minElevation = 0.0)
) )
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0)) override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
@@ -217,15 +379,15 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources) override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
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 setSelectedTypes(types: List<String>) = Unit override fun setSelectedSatModes(modes: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit override fun setPassesSettings(settings: PassesSettings) = Unit
@@ -235,11 +397,6 @@ 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
@@ -253,6 +410,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
dataSourcesSettings.value = settings dataSourcesSettings.value = settings
} }
override fun updateDataSourcesStatus(status: Map<String, Int>) {
dataSourcesStatus.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
@@ -0,0 +1,253 @@
/*
* 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 })
}
@Test
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("ao7")
val items = flow.first()
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
// guarantee is that AO-7 — unreachable before because of dashes/brackets
// — is now found.
assertEquals(7530, items.first().catnum)
assertEquals(listOf(7530, 39444), items.map { it.catnum })
}
@Test
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("fo29")
assertEquals(listOf(99999), flow.first().map { it.catnum })
}
@Test
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zarya iss")
assertEquals(listOf(25544), flow.first().map { it.catnum })
}
@Test
fun `partial token query matches substring`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("funcube")
assertEquals(listOf(39444), flow.first().map { it.catnum })
}
@Test
fun `unmatched query returns nothing`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zzzznomatch")
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
}
private fun repoWithSearchEntries(): SelectionRepo {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
SatItem(39444, "AO-73 (FUNcube-1)", false),
SatItem(43803, "JO-97 (BIRDS-3)", false),
SatItem(99999, "FO-29", false)
)
)
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
}
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 getEntriesEpochs(): Map<Int, Double> = emptyMap()
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
override suspend fun renameEntries(names: Map<Int, String>) = Unit
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
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>, isCustom: Boolean) = Unit
override suspend fun deleteRadios() = Unit
override suspend fun deleteManagedRadios() = Unit
}
private class FakeSettingsRepo(
selectedModes: List<String>
) : ISettingsRepo {
override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(emptyList(), emptyList())
)
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) {
selectedSatModes.value = modes
}
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
}
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
}
}
@@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
* missing optional field should not cost the whole transceiver. */
@Serializable @Serializable
data class SatRadio( data class SatRadio(
@SerialName("uuid") val uuid: String, @SerialName("uuid") val uuid: String,
@SerialName("description") val info: String, @SerialName("description") val info: String = "",
@SerialName("alive") val isAlive: Boolean, @SerialName("alive") val isAlive: Boolean = true,
@SerialName("downlink_low") val downlinkLow: Long?, @SerialName("downlink_low") val downlinkLow: Long? = null,
@SerialName("downlink_high") val downlinkHigh: Long?, @SerialName("downlink_high") val downlinkHigh: Long? = null,
@SerialName("mode") val downlinkMode: String?, @SerialName("mode") val downlinkMode: String? = null,
@SerialName("uplink_low") val uplinkLow: Long?, @SerialName("uplink_low") val uplinkLow: Long? = null,
@SerialName("uplink_high") val uplinkHigh: Long?, @SerialName("uplink_high") val uplinkHigh: Long? = null,
@SerialName("uplink_mode") val uplinkMode: String?, @SerialName("uplink_mode") val uplinkMode: String? = null,
@SerialName("invert") val isInverted: Boolean, @SerialName("invert") val isInverted: Boolean = false,
@SerialName("norad_cat_id") val catnum: Int? @SerialName("norad_cat_id") val catnum: Int? = 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, 3 days of state */ /** One satellite, 5 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> // 3 days (newest first) val days: List<SatDay> // 5 天(新→旧)
) )
/** Overall page parse result */ /** Overall page parse result */
@@ -29,8 +29,7 @@ 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(
@@ -63,13 +62,26 @@ 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 source URLs (TLE / transceivers) and their per-source enabled flags.
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
*/
data class DataSourcesSettings( data class DataSourcesSettings(
val satelliteUrls: List<String>, val satelliteUrls: List<String>,
val transceiversUrls: List<String> val transceiversUrls: List<String>,
) val satelliteEnabled: List<Boolean> = emptyList(),
val transceiversEnabled: List<Boolean> = emptyList()
) {
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class RadioControlSettings( data class RadioControlSettings(
val enabled: Boolean, val enabled: Boolean,
@@ -432,7 +432,7 @@ object CelestialComputer {
daynum = sunrise + 0.001 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))
} }
@@ -37,30 +37,42 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min) val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
/** Absolute age of this element set, used to pick the freshest data among several sources. */
val epochDaynum: Double = epochToDaynum(epoch)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this) fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */ /** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean { fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0 return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
} }
private fun epochToDaynum(epoch: Double): Double { companion object {
var year = kotlin.math.floor(epoch * 1E-3) /** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
val day = (epoch * 1E-3 - year) * 1000.0 fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double { /**
val aYear = theYear - 1 * Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
val a = kotlin.math.floor(aYear / 100).toLong() * comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
val b = 2 - a + a / 4 * freshness checks must always go through this conversion.
val i = kotlin.math.floor(365.25 * aYear).toLong() */
return i + (30.6001 * 14).toLong() + 1720994.5 + b fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
} }
} }
@@ -6,17 +6,8 @@ import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */ /** AMSAT satellite status data source */
interface IAmSatRepository { interface IAmSatRepository {
/** Cached AMSAT status page for the current foreground session, if any. */ /** Fetch and parse the AMSAT status page; null on failure */
fun getCachedStatus(): SatStatusPage? suspend fun fetchStatus(): 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. */ /** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
@@ -16,13 +16,12 @@
* 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
@@ -32,8 +31,6 @@ 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
@@ -43,27 +40,9 @@ 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
} }
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface ISelectionRepo { interface ISelectionRepo {
fun getCurrentTypes(): List<String> fun getCurrentModes(): List<String>
fun getTypesList(): List<String> fun getModesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>> suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setTypes(types: List<String>) suspend fun setModes(modes: List<String>)
suspend fun setQuery(query: String) suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean) suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean) suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
@@ -29,12 +29,13 @@ import kotlinx.coroutines.flow.StateFlow
interface ISettingsRepo { interface ISettingsRepo {
val appVersionName: String val appVersionName: String
val appVersionCode: Long
//region # Satellites selection settings //region # Satellites selection settings
val selectedIds: StateFlow<List<Int>> val selectedIds: StateFlow<List<Int>>
val selectedTypes: StateFlow<List<String>> val selectedSatModes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>) fun setSelectedIds(ids: List<Int>)
fun setSelectedTypes(types: List<String>) fun setSelectedSatModes(modes: List<String>)
//endregion //endregion
//region # Passes filter settings //region # Passes filter settings
@@ -51,8 +52,6 @@ interface ISettingsRepo {
//region # Database update settings //region # Database update settings
val databaseState: StateFlow<DatabaseState> val databaseState: StateFlow<DatabaseState>
fun getSatelliteTypesIds(types: List<String>): List<Int>
fun setSatelliteTypeIds(type: String, ids: List<Int>)
fun updateDatabaseState(state: DatabaseState) fun updateDatabaseState(state: DatabaseState)
//endregion //endregion
@@ -73,6 +72,11 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings) fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion //endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings //region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -26,11 +26,18 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem> suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
suspend fun getEntriesEpochs(): Map<Int, Double>
suspend fun getEntriesNames(): Map<Int, String>
suspend fun renameEntries(names: Map<Int, String>)
suspend fun deleteEntriesWithIds(ids: List<Int>)
suspend fun insertEntries(entries: List<OrbitalData>) suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries() suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>) suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
suspend fun deleteRadios() suspend fun deleteRadios()
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
suspend fun deleteManagedRadios()
} }
@@ -19,9 +19,17 @@ package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream import java.io.InputStream
/** Result of a network download: the HTTP status code and the response body stream. */
data class NetworkResult(val code: Int, val stream: InputStream?) {
companion object {
/** Code used when a request fails before any HTTP response is received. */
const val CONNECTION_ERROR = -1
}
}
interface IRemoteSource { interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream? suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream? suspend fun getNetworkStream(url: String): NetworkResult
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>? suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
@@ -18,40 +18,27 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
val satelliteDataUrls = mapOf( // Ordered from the most specific to the most generic source: the first source that knows a
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", // satellite gets to name it, with the full catalog acting as the fallback.
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le", val satelliteDataUrls = listOf(
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv", "live.ariss.org/iss.txt",
"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", "mmccants.org/tles/classfd.zip",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv", "amsat.org/tle/current/nasabare.txt",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv", "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv", "db.satnogs.org/api/tle/?format=3le"
"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 = mapOf( val transceiversDataUrls = listOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active", "r4uab.ru/transmitters.json",
"R4UAB" to "https://r4uab.ru/transmitters.json" "db.satnogs.org/api/transmitters/?format=json&status=active"
)
val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
) )
} }
@@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
ignoreUnknownKeys = true ignoreUnknownKeys = true
coerceInputValues = true coerceInputValues = true
} }
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
private val celestrakCSVColumns = mapOf(
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
)
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines -> stream.bufferedReader().useLines { lines ->
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList() val iterator = lines.iterator()
if (!iterator.hasNext()) return@useLines emptyList()
val columns = parseCSVColumns(iterator.next())
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
} }
} }
@@ -58,40 +67,68 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
} }
private fun parseCSV(values: List<String>): OrbitalData? = runCatching { private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
val name = values[0] fun value(column: String) = values[columns.getValue(column)].trim()
val timestamp = values[2] fun optionalValue(column: String) =
val year = timestamp.substring(0, 4) columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt()
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
val day = dayInt.toString().padStart(3, '0')
val hour = timestamp.substring(11, 13).toInt() * 3600000
val min = timestamp.substring(14, 16).toInt() * 60000
val sec = timestamp.substring(17, 19).toInt() * 1000
val ms = timestamp.substring(20, 26).toInt() / 1000.0
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
OrbitalData( OrbitalData(
name = name, name = value("OBJECT_NAME"),
epoch = epoch, epoch = parseTimestamp(value("EPOCH")),
meanmo = values[3].toDouble(), meanmo = value("MEAN_MOTION").toDouble(),
eccn = values[4].toDouble(), eccn = value("ECCENTRICITY").toDouble(),
incl = values[5].toDouble(), incl = value("INCLINATION").toDouble(),
raan = values[6].toDouble(), raan = value("RA_OF_ASC_NODE").toDouble(),
argper = values[7].toDouble(), argper = value("ARG_OF_PERICENTER").toDouble(),
meanan = values[8].toDouble(), meanan = value("MEAN_ANOMALY").toDouble(),
catnum = values[11].toInt(), catnum = parseCatnum(value("NORAD_CAT_ID")),
bstar = values[14].toDouble(), bstar = optionalValue("BSTAR"),
ndot = values[15].toDouble() ndot = optionalValue("MEAN_MOTION_DOT")
) )
}.onFailure { println("CSV parsing exception: $it") }.getOrNull() }.onFailure { println("CSV parsing exception: $it") }.getOrNull()
/**
* OMM columns are located by name, as providers agree on the names but not on the order.
* Falls back to the Celestrak layout when the header is missing or unrecognized.
*/
private fun parseCSVColumns(header: String): Map<String, Int> {
val columns = header.split(",").withIndex().associate { (index, name) ->
name.trim().trim('"').uppercase() to index
}
return if (columns.containsKey("NORAD_CAT_ID")) columns else celestrakCSVColumns
}
/**
* ISO 8601 timestamp as a TLE style YYDDD.ffffffff epoch. Fractional seconds are optional and
* a trailing timezone marker is ignored, as not every provider formats the epoch alike.
*/
private fun parseTimestamp(timestamp: String): Double {
val year = timestamp.substring(0, 4).toInt()
val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt()
val hours = timestamp.substring(11, 13).toInt()
val minutes = timestamp.substring(14, 16).toInt()
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble()
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction
}
/**
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5:
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0.
*/
private fun parseCatnum(value: String): Int {
val catnum = value.trim()
if (catnum.first().isDigit()) return catnum.toInt()
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar())
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" }
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt()
}
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching { private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
val line1 = tle[1] val line1 = tle[1]
val line2 = tle[2] val line2 = tle[2]
OrbitalData( OrbitalData(
name = tle[0].trim(), name = tle[0].trim().removePrefix("0 "),
epoch = line1.substring(18, 32).toDouble(), epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(), meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7, eccn = line2.substring(26, 33).toDouble() / 1e7,
@@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
raan = line2.substring(17, 25).toDouble(), raan = line2.substring(17, 25).toDouble(),
argper = line2.substring(34, 42).toDouble(), argper = line2.substring(34, 42).toDouble(),
meanan = line2.substring(43, 51).toDouble(), meanan = line2.substring(43, 51).toDouble(),
catnum = line1.substring(2, 7).trim().toInt(), catnum = parseCatnum(line1.substring(2, 7)),
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()), bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
ndot = line1.substring(33, 43).trim().toDouble() ndot = line1.substring(33, 43).trim().toDouble()
) )
@@ -16,28 +16,18 @@ import java.util.Locale
/** /**
* Computes Doppler-corrected reciprocal frequencies for linear transponders. * Computes Doppler-corrected reciprocal frequencies for linear transponders.
* *
* The full physical path: * For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, this computes the other:
* *
* TX→RX (uplink → downlink): * downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* ① 地面发射 f_tx * uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
* ② 卫星收到 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 (what the user hears), compute the * Given a downlink frequency, compute the Doppler-corrected uplink frequency.
* uplink frequency the user should transmit. * Returns null if the transponder is not a linear passband type.
* Full path: ④→③→②→①
*/ */
fun computeUplinkFromDownlink( fun computeUplinkFromDownlink(
downlinkHz: Long, downlinkHz: Long,
@@ -45,22 +35,17 @@ 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
val satTx = orbitalPos.getUplinkFreq(downlinkHz) return orbitalPos.getUplinkFreq(baseUplink)
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given a downlink frequency (what the user hears), compute the * Given a downlink frequency, compute the Doppler-corrected uplink frequency
* uplink frequency the user should transmit, with an offset applied * with an offset applied to the downlink (in Hz).
* to the downlink (in Hz). * Returns null if the transponder is not a linear passband type.
* 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 the inverse downlink Doppler. * it before mapping the downlink passband position back to the uplink.
*/ */
fun computeUplinkFromDownlinkWithOffset( fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long, downlinkHz: Long,
@@ -69,20 +54,13 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset) val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz) return orbitalPos.getUplinkFreq(baseUplink)
// ③ 去掉 offset(offset 在卫星本地频率域)
val satTx = satTxWithOffset - offsetHz
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given an uplink frequency (what the user transmits), compute the * Given an uplink frequency, compute the Doppler-corrected downlink frequency.
* downlink frequency the user will hear. * Returns null if the transponder is not a linear passband type.
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplink( fun computeDownlinkFromUplink(
uplinkHz: Long, uplinkHz: Long,
@@ -90,19 +68,14 @@ 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
val satRx = orbitalPos.getDownlinkFreq(uplinkHz) return orbitalPos.getDownlinkFreq(baseDownlink)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx)
} }
/** /**
* Given an uplink frequency (what the user transmits), compute the * Given an uplink frequency, compute the Doppler-corrected downlink frequency
* downlink frequency the user will hear, with an offset applied * with an offset applied to the downlink (in Hz).
* to the downlink (in Hz). * Returns null if the transponder is not a linear passband type.
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplinkWithOffset( fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long, uplinkHz: Long,
@@ -111,13 +84,8 @@ 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
val satRx = orbitalPos.getDownlinkFreq(uplinkHz) return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
// ②→③ 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. */
@@ -134,8 +102,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 the named linear * can also have low/high frequencies. The calculator is meant for named linear
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder". * transponders, 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,6 +41,21 @@ 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,10 +18,12 @@
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
import org.junit.Test import org.junit.Test
import kotlin.math.abs
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class DataParserTest { class DataParserTest {
@@ -60,8 +62,49 @@ class DataParserTest {
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) { fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream) val parsedList = dataParser.parseCSVStream(validCSVStream)
assert(parsedList.size == 2) assert(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234) assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
assert(parsedList[1].epoch == 24069.23963816) assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8)
}
@Test
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
val csvStream = """
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
""".trimIndent().byteInputStream()
val sat = dataParser.parseCSVStream(csvStream)[0]
assert(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035)
assert(sat.bstar == 0.31985E-4)
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
}
@Test
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
val csvStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
val parsedList = dataParser.parseCSVStream(csvStream)
assert(parsedList.size == 2)
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
}
@Test
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
val tleStream = """
NAVSTAR 43
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
val sat = dataParser.parseTLEStream(tleStream)[0]
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
assert(sat.catnum == 270111)
} }
@Test @Test
@@ -239,4 +282,10 @@ 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)
}
} }
@@ -3,7 +3,11 @@ 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.* import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class DopplerFrequencyCalculatorTest { class DopplerFrequencyCalculatorTest {
@@ -162,8 +166,6 @@ 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)
} }
@@ -178,24 +180,23 @@ 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) // ~7 km/s receding val orbitalPos = pos(7.0)
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_fm_transponder_returnsNull() { fun computeUplinkFromDownlink_fmTransponder_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_fm_transponder_returnsNull() { fun computeDownlinkFromUplink_fmTransponder_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)
@@ -254,13 +255,11 @@ 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
@@ -268,7 +267,6 @@ 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)
} }
@@ -0,0 +1,79 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import kotlin.math.abs
class CelestialComputerTest {
private data class RiseSetCase(
val name: String,
val observer: GeoPos,
val startIso: String
)
@Test
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
val cases = listOf(
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
)
cases.forEach { testCase ->
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
}
}
@Test
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
}
}
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
private companion object {
private const val SUNRISE_SET_THRESHOLD = -0.8333
private const val HOUR_MILLIS = 60L * 60L * 1000L
private const val DAY_MILLIS = 24L * HOUR_MILLIS
}
}
@@ -27,13 +27,18 @@ 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
@@ -47,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -71,6 +76,8 @@ 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
@@ -143,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
} }
val sdfTime = remember(isUtc) { val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone } SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
} }
ElevatedCard( ElevatedCard(
modifier = modifier modifier = modifier
@@ -157,13 +164,16 @@ 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
@@ -209,6 +219,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
} }
} }
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Composable @Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) { fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton( ElevatedButton(
@@ -224,8 +239,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
} }
@Composable @Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) { fun IconCard(
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) { action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
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)
} }
@@ -315,51 +335,52 @@ fun InfoDialog(
} }
@Composable @Composable
fun SharedDialog( fun ConfirmDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) {
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
content()
}
}
@Composable
fun SharedDialog(
title: String, title: String,
onDismissRequest: () -> Unit, onCancel: () -> Unit,
onCancel: (() -> Unit)? = null, onAccept: () -> Unit,
onAccept: (() -> Unit)? = null, content: @Composable () -> Unit
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
) { ) {
DialogShell(onDismissRequest = onDismissRequest) { padding -> DialogShell(onDismissRequest = onCancel) { 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)
) { ) {
if (onCancel != null) { CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
}
Text( Text(
text = title, text = title,
fontSize = titleFontSize.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = titleTextAlign, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp) .padding(horizontal = padding)
) )
if (onAccept != null) { CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
} }
content(padding) content()
}
}
@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))
} }
} }
@@ -371,6 +392,9 @@ 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 {
@@ -388,7 +412,7 @@ private fun DialogShell(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
sheetState = sheetState, sheetState = sheetState,
dragHandle = null, dragHandle = null,
shape = MaterialTheme.shapes.medium, shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
scrimColor = Color.Black.copy(alpha = 0.64f) scrimColor = Color.Black.copy(alpha = 0.64f)
) { ) {
Column( Column(
@@ -396,6 +420,7 @@ 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)
@@ -405,11 +430,11 @@ private fun DialogShell(
@Composable @Composable
fun hasEnoughHeight(): Boolean = fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480) currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
@Composable @Composable
fun hasEnoughWidth(): Boolean = fun hasEnoughWidth(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600) currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
@Composable @Composable
fun isVerticalLayout(): Boolean = !hasEnoughWidth() fun isVerticalLayout(): Boolean = !hasEnoughWidth()
@@ -0,0 +1,272 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
//region DragDrop Region
@Composable
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
offset.y.toInt() in item.offset..(item.offset + item.size)
}?.also {
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f, spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index
}
if (targetItem != null) {
if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) {
state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset)
}
onMove.invoke(draggingItem.index, targetItem.index)
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 -> (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val zIndexMod = Modifier.zIndex(1f)
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier = when {
dragging -> zIndexMod.graphicsLayer { translationY = dragDropState.draggingItemOffset }
index == dragDropState.previousIndexOfDraggedItem -> {
zIndexMod.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
}
else -> Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}
//endregion
//region Swipe Region
@Composable
fun SwipeableItem(onRemove: () -> Unit, content: @Composable () -> Unit) {
val coroutineScope = rememberCoroutineScope()
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val isVisible = remember { mutableStateOf(true) }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
hapticFeedback.performHapticFeedback(feedbackType)
}
AnimatedVisibility(visible = isVisible.value, exit = fadeOut(spring())) {
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = false,
backgroundContent = { DismissBackground(willTrigger) },
content = { content() },
onDismiss = {
isVisible.value = false
coroutineScope.launch { dismissState.reset() }
}
)
}
LaunchedEffect(isVisible.value) {
if (!isVisible.value) { delay(250.milliseconds).also { onRemove() } }
}
}
@Composable
fun DismissBackground(willTrigger: Boolean, bgColor: Color = Color(0xFFFF3C3A)) {
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxSize()
.border(width = 2.dp, shape = MaterialTheme.shapes.extraLarge, color = bgColor)
.dropShadow(shape = MaterialTheme.shapes.extraLarge) {
color = bgColor
radius = 40f
alpha = if (willTrigger) .2f else 0f
}
.innerShadow(shape = MaterialTheme.shapes.extraLarge) {
color = bgColor
radius = 40f
alpha = if (willTrigger) 1f else .2f
}
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.fillMaxHeight()
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
translationX = slide.toPx()
}
)
}
}
//endregion
@@ -0,0 +1,311 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
/**
* Long-press drag-to-reorder for rows inside a `LazyColumn`, with built-in edge auto-scroll and
* a settle-back animation when the finger is released.
*
* Reordering only ever swaps an item with its neighbor *inside the `items` list* passed to
* [rememberDragRowState] — so multiple independent groups of rows (e.g. two sections separated
* by header items) can share one [DragReorderState]/`LazyColumn` simply by giving each section
* its own backing list and calling [rememberDragRowState] with that section's list. Keys must be
* globally unique across the whole `LazyColumn` and match exactly what is passed as the
* `key = { ... }` lambda of `items`/`itemsIndexed`.
*
* Usage:
* ```
* val dragState = rememberDragReorderState(listState)
* itemsIndexed(entries, key = { _, e -> e.id }) { index, entry ->
* val row = rememberDragRowState(dragState, entries, entry, key = { it.id }) { from, to ->
* entries.add(to, entries.removeAt(from))
* }
* Row(
* modifier = Modifier
* .dragLift(row.isLifted, row.translationY)
* .animateItem(placementSpec = if (row.isDragging) tween(0) else spring())
* ) {
* Icon(modifier = Modifier.dragHandle(row), painter = ..., contentDescription = null)
* Text(entry.label)
* }
* }
* ```
*/
@Stable
class DragReorderState internal constructor(internal val listState: LazyListState) {
internal var draggedKey by mutableStateOf<Any?>(null)
}
@Composable
fun rememberDragReorderState(listState: LazyListState): DragReorderState =
remember(listState) { DragReorderState(listState) }
/** Used for the settle-back animation once the finger is released: quick and snappy. */
private val reorderSettleSpec = tween<Float>(durationMillis = 150, easing = FastOutSlowInEasing)
/**
* Per-row drag bookkeeping, obtained via [rememberDragRowState].
*
* [dragStartOffset] is this row's viewport offset captured once at drag start, [fingerOffset] is
* the raw accumulated finger travel (used for edge auto-scroll and swap detection). [translationY]
* is derived every frame from those two plus the row's *current* live layout offset, so it
* automatically stays correct across neighbor swaps and list auto-scroll without any manual
* offset-compensation math.
*/
@Stable
class DragRowState<T> internal constructor(
private val dragState: DragReorderState,
private val itemsState: State<List<T>>,
private val itemKey: Any,
private val key: (T) -> Any,
private val onMoveState: State<(Int, Int) -> Unit>,
private val scope: CoroutineScope
) {
internal val fingerOffset: MutableFloatState = mutableFloatStateOf(0f)
internal val dragStartOffset: MutableFloatState = mutableFloatStateOf(0f)
internal val startCenterY: MutableFloatState = mutableFloatStateOf(0f)
internal val settleAnim = Animatable(0f)
var isSettling: Boolean by mutableStateOf(false)
private set
val isDragging: Boolean get() = dragState.draggedKey == itemKey
val isLifted: Boolean get() = isDragging || isSettling
val translationY: Float
get() = when {
isSettling -> settleAnim.value
isDragging -> currentTranslation()
else -> 0f
}
/**
* How far this row must be pushed away from its *current* layout slot so it stays glued to
* the finger. Because [dragStartOffset] is fixed at drag start while the row's live layout
* offset moves as neighbors swap places or the list auto-scrolls, this difference naturally
* absorbs both effects with no extra bookkeeping.
*/
private fun currentTranslation(): Float {
val liveOffset = dragState.listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == itemKey }?.offset?.toFloat()
?: dragStartOffset.floatValue
return dragStartOffset.floatValue + fingerOffset.floatValue - liveOffset
}
internal fun onDragStart() {
isSettling = false
val layout = dragState.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey }
dragStartOffset.floatValue = (layout?.offset ?: 0).toFloat()
startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
fingerOffset.floatValue = 0f
dragState.draggedKey = itemKey
}
internal fun onDrag(deltaY: Float) {
if (dragState.draggedKey != itemKey) return
fingerOffset.floatValue += deltaY
reorderLive()
}
private fun reorderLive() {
val items = itemsState.value
val myIndex = items.indexOfFirst { key(it) == itemKey }
if (myIndex !in items.indices) return
val myCenter = startCenterY.floatValue + fingerOffset.floatValue
val visible = dragState.listState.layoutInfo.visibleItemsInfo
val onMove = onMoveState.value
// Dragging down: swap when the dragged center passes the next row's midpoint.
if (myIndex < items.lastIndex) {
val nextKey = key(items[myIndex + 1])
val next = visible.firstOrNull { it.key == nextKey }
if (next != null && myCenter > next.offset + next.size / 2f) {
onMove(myIndex, myIndex + 1)
return
}
}
// Dragging up: swap when the dragged center passes the previous row's midpoint.
if (myIndex > 0) {
val prevKey = key(items[myIndex - 1])
val prev = visible.firstOrNull { it.key == prevKey }
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
onMove(myIndex, myIndex - 1)
}
}
}
// Reset the drag bookkeeping and fly the lifted row back into its slot.
// All state resets happen inside the launched block so the settle animation takes over from
// the current visual position without a one-frame jump: isSettling is flipped to true
// (switching rendering to settleAnim, already snapped to the last offset) before the drag
// flags are cleared.
internal fun onDragEnd() {
val lastOffset = currentTranslation()
if (kotlin.math.abs(lastOffset) < 1f) {
fingerOffset.floatValue = 0f
dragState.draggedKey = null
return
}
scope.launch {
settleAnim.snapTo(lastOffset)
isSettling = true
fingerOffset.floatValue = 0f
dragState.draggedKey = null
settleAnim.animateTo(0f, reorderSettleSpec)
isSettling = false
}
}
}
/**
* Remembers a [DragRowState] for one row of a drag-reorderable list.
*
* [items] must be the exact (optionally section-scoped) list backing the enclosing
* `items`/`itemsIndexed` call, and [key] must return the same value used as that call's
* `key = { ... }` lambda. Reordering only ever swaps neighbors within [items], so passing a
* section-local list is what confines dragging to one section of a multi-section `LazyColumn`.
*/
@Composable
fun <T> rememberDragRowState(
dragState: DragReorderState,
items: List<T>,
item: T,
key: (T) -> Any,
onMove: (from: Int, to: Int) -> Unit
): DragRowState<T> {
val scope = rememberCoroutineScope()
val itemsState = rememberUpdatedState(items)
val onMoveState = rememberUpdatedState(onMove)
val itemKey = key(item)
val rowState = remember(dragState, itemKey) {
DragRowState(dragState, itemsState, itemKey, key, onMoveState, scope)
}
LaunchedEffect(rowState.isDragging) {
if (!rowState.isDragging) return@LaunchedEffect
autoScrollWhileDragging(dragState.listState, rowState.startCenterY, rowState.fingerOffset)
}
return rowState
}
/** Drag-handle gesture: attach to a small handle icon to start/drive/end reordering of [rowState]'s row. */
fun Modifier.dragHandle(rowState: DragRowState<*>): Modifier = pointerInput(rowState) {
detectDragGesturesAfterLongPress(
onDragStart = { rowState.onDragStart() },
onDragEnd = { rowState.onDragEnd() },
onDragCancel = { rowState.onDragEnd() }
) { change, dragAmount ->
change.consume()
rowState.onDrag(dragAmount.y)
}
}
/**
* Visual treatment for a draggable row: while [isLifted], translates the row by [translationY],
* scales it up slightly and raises it above its neighbors with a shadow and a solid background so
* it fully covers the row beneath instead of showing a translucent overlap of both rows.
*/
@Composable
fun Modifier.dragLift(isLifted: Boolean, translationY: Float): Modifier {
val shape = MaterialTheme.shapes.small
val scale by animateFloatAsState(
targetValue = if (isLifted) 1.02f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "dragScale"
)
return this
.graphicsLayer {
if (isLifted) {
this.translationY = translationY
scaleX = scale
scaleY = scale
}
}
.then(
if (isLifted) {
Modifier
.zIndex(1f)
.shadow(8.dp, shape, clip = false)
.background(MaterialTheme.colorScheme.surface, shape)
} else {
Modifier
}
)
}
/**
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
* The visual center is tracked independently of the entry's layout slot (which can scroll out of
* `LazyListState.layoutInfo.visibleItemsInfo` during a long drag); [startCenterY] is the entry's
* viewport center captured at drag start and [fingerOffset] is the raw finger delta. The
* resulting scroll is picked up automatically by [DragRowState.translationY] on the next frame,
* so no separate scroll-compensation bookkeeping is required here.
*/
private suspend fun autoScrollWhileDragging(
listState: LazyListState,
startCenterY: MutableFloatState,
fingerOffset: MutableFloatState
) {
val threshold = 48f
val maxSpeed = 24f
while (true) {
val info = listState.layoutInfo
val center = startCenterY.floatValue + fingerOffset.floatValue
val top = info.viewportStartOffset + threshold
val bottom = info.viewportEndOffset - threshold
val delta = when {
center < top -> -(top - center).coerceAtMost(maxSpeed)
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
else -> 0f
}
if (delta != 0f) listState.scrollBy(delta)
delay(16L.milliseconds)
}
}
@@ -46,7 +46,7 @@ data object Spacing {
val extraSmall = 6.dp val extraSmall = 6.dp
val small = 8.dp val small = 8.dp
val medium = 12.dp val medium = 12.dp
val large = 16.dp val large = 12.dp
val extraLarge = 24.dp val extraLarge = 24.dp
} }
@@ -24,20 +24,17 @@ 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_satellites, R.string.nav_sat) data object Satellites : Screen(R.drawable.ic_sputnik, 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
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
@Serializable @Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map) data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable
data object Mutual : Screen(R.drawable.ic_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,3 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
<path android:fillColor="@android:color/white" android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" /> android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector> </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="M20,9H4v2h16V9zM4,15h16v-2H4V15z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="15"
android:viewportHeight="15">
<path
android:fillColor="@android:color/white"
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
</vector>
@@ -1,22 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
</vector>
@@ -1,9 +1,9 @@
<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="960"
android:viewportHeight="24"> android:viewportHeight="960">
<path <path
android:fillColor="@android:color/white" 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" /> android:pathData="M240,840L240,760Q240,760 256.5,750Q273,740 292.5,720.5Q312,701 328,670.5Q344,640 344,600Q344,589 342.5,579Q341,569 339,560L240,560L240,480L300,480Q279,447 259.5,410.5Q240,374 240,320Q240,228 304,164Q368,100 460,100Q531,100 586,139Q641,178 665,240L591,271Q576,231 540.5,205.5Q505,180 460,180Q402,180 361,221Q320,262 320,320Q320,368 344,400Q368,432 393,480L560,480L560,560L421,560Q423,569 423.5,579Q424,589 424,600Q424,650 406.5,690Q389,730 364,760L560,760Q600,760 621,739Q642,718 650,685L720,720Q709,775 663.5,807.5Q618,840 560,840L240,840Z" />
</vector> </vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</vector>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#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,12 +1,10 @@
<?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="#FF000000" android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" /> 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>
@@ -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="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
</vector>
@@ -0,0 +1,31 @@
<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>
@@ -1,19 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
</vector>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Ajustes</string> <string name="nav_prefs">Ajustes</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Tipo: %s</string> <string name="sat_type_hint">Modos: %s</string>
<string name="sat_type_title">Seleccionar tipo de satélite</string> <string name="sat_type_title">Seleccionar modos</string>
<string name="sat_search_hint">Id - Nombre</string> <string name="sat_search_hint">Id - Nombre</string>
<string name="sat_search_clear">Vaciar</string> <string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string> <string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string> <string name="sat_select_all">Seleccionar todo</string>
<string name="sat_group_selected">Seleccionados</string>
<string name="sat_group_available">Disponibles</string>
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
<string name="sat_group_available_count">Disponibles (%1$d)</string>
<string name="sat_empty_list_message"> <string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string> Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string> <string name="sat_warning_title">Alarma\!</string>
@@ -26,7 +30,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 tipos.</string> interesen mediante la búsqueda y el selector de modos.</string>
<!-- Passes screen --> <!-- Passes screen -->
<string name="pass_filter_title">Filtrar pases</string> <string name="pass_filter_title">Filtrar pases</string>
@@ -35,7 +39,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 &gt;225min)</string> <string name="pass_filter_deep_space">DeepSpace (período &gt;225min)</string>
<string name="pass_modes_title">Tipo de modulación</string> <string name="pass_modes_title">Seleccionar modos</string>
<string name="pass_elevation">Elevación: %.1f°</string> <string name="pass_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">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string> <string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
<string name="prefs_loc_success">Ubicación actualizada con éxito</string> <string name="prefs_loc_success">Ubicación actualizada con éxito</string>
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string> <string name="prefs_station_title">Ubicación de la estación</string>
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string> <string name="prefs_station_lat_text">Latitud de estación</string>
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string> <string name="prefs_station_lon_text">Longitud de estación</string>
<string name="prefs_locator_title">Ajustes ubicación QTH</string> <string name="prefs_locator_title">Localizador QTH</string>
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string> <string name="prefs_locator_text">Posición por localizador</string>
<string name="prefs_data_title">Datos satelitales</string> <string name="prefs_data_title">Datos satelitales</string>
<string name="prefs_data_entries">Satélites: %s</string> <string name="prefs_data_entries">Satélites: %s</string>
@@ -110,46 +114,57 @@
<string name="prefs_data_clear">Vaciar</string> <string name="prefs_data_clear">Vaciar</string>
<string name="prefs_data_clear_success">Datos eliminados con éxito</string> <string name="prefs_data_clear_success">Datos eliminados con éxito</string>
<string name="prefs_data_update_success">Actualización completada con éxito</string> <string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_sources_sat_title">Datos de satélites</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string> <string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string> <string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string> <string name="prefs_data_sources_tle_switch">URL TLE</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_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">Salida de datos</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">Red</string>
<string name="prefs_bt_output">Bluetooth</string> <string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Salida de datos de red</string> <string name="nav_radiocontrol">Control de radio</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="rc_settings_title">Control CAT</string>
<string name="rc_radio_model">Modelo de radio</string>
<string name="rc_tx_device_hint">Dirección BT TX</string>
<string name="rc_rx_device_hint">Dirección BT RX</string>
<string name="rc_tx_name_hint">Nombre radio TX</string>
<string name="rc_rx_name_hint">Nombre radio RX</string>
<string name="rc_enable_switch">Activar CAT</string>
<string name="prefs_net_title">Salida de red</string>
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
<string name="prefs_net_rotator_address_hint">IP:Puerto</string> <string name="prefs_net_rotator_address_hint">IP:Puerto</string>
<string name="prefs_net_rotator_format_hint">Formato de datos</string> <string name="prefs_net_rotator_format_hint">Formato de datos</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_net_frequency_address_hint">IP:Puerto</string> <string name="prefs_net_frequency_address_hint">IP:Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string> <string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string> <string name="prefs_bt_title">Salida Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string> <string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string> <string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato de datos</string> <string name="prefs_bt_rotator_output_hint">Formato</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string> <string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string> <string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato de datos</string> <string name="prefs_bt_frequency_output_hint">Formato</string>
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string> <string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
<string name="prefs_net_perm_error">Revisa los permisos de red</string> <string name="prefs_net_perm_error">Revisa los permisos de red</string>
<string name="prefs_other_title">Otros ajustes</string> <string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string> <string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string> <string name="prefs_other_switch_update">Actualización automática</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string> <string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string> <string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string> <string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_highlight_title">Resaltado de elevación</string> <string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string> <string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<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>
@@ -34,7 +38,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 (период &gt;225мин)</string> <string name="pass_filter_deep_space">DeepSpace (период &gt;225мин)</string>
<string name="pass_modes_title">Выберите тип модуляции</string> <string name="pass_modes_title">Выберите режимы</string>
<string name="pass_elevation">Элевация: %.1f°</string> <string name="pass_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">
@@ -95,11 +99,11 @@
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string> <string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
<string name="prefs_loc_success">Позиция успешно обновлена</string> <string name="prefs_loc_success">Позиция успешно обновлена</string>
<string name="prefs_station_title">Настройки местоположения</string> <string name="prefs_station_title">Положение станции</string>
<string name="prefs_station_lat_text">Введите широту наземной станции</string> <string name="prefs_station_lat_text">Широта станции</string>
<string name="prefs_station_lon_text">Введите долготу наземной станции</string> <string name="prefs_station_lon_text">Долгота станции</string>
<string name="prefs_locator_title">Настройки локатора QTH</string> <string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Введите позицию через локатор</string> <string name="prefs_locator_text">Позиция по локатору</string>
<string name="prefs_data_title">Данные спутников</string> <string name="prefs_data_title">Данные спутников</string>
<string name="prefs_data_entries">Спутников: %s</string> <string name="prefs_data_entries">Спутников: %s</string>
@@ -109,23 +113,35 @@
<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_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_sources_sat_title">Данные спутников</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string> <string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string> <string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string> <string name="prefs_data_sources_tle_switch">URL TLE</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_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</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="prefs_net_title">Вывод сетевых данных</string> <string name="nav_radiocontrol">Управление радио</string>
<string name="rc_settings_title">CAT управление</string>
<string name="rc_radio_model">Модель радио</string>
<string name="rc_tx_device_hint">BT адрес TX</string>
<string name="rc_rx_device_hint">BT адрес RX</string>
<string name="rc_tx_name_hint">Имя радио TX</string>
<string name="rc_rx_name_hint">Имя радио RX</string>
<string name="rc_enable_switch">Включить CAT</string>
<string name="prefs_net_title">Сетевой вывод</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string> <string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string> <string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string> <string name="prefs_net_rotator_format_hint">Формат</string>
@@ -133,22 +149,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">Низко &lt; %1$d°</string> <string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<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 -->
@@ -35,7 +39,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 (කාලය &gt;225min)</string> <string name="pass_filter_deep_space">DeepSpace (කාලය &gt;225min)</string>
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string> <string name="pass_modes_title">මූර්ජන තෝරන්න</string>
<string name="pass_elevation">උත්තෝලනය: %.1f°</string> <string name="pass_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">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string> <string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string> <string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string> <string name="prefs_station_title">පොළ පිහිටීම</string>
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න</string> <string name="prefs_station_lat_text">පොළ අක්‍ෂාංශය</string>
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string> <string name="prefs_station_lon_text">පොළ දේශාංශය</string>
<string name="prefs_locator_title">QTH locator පසුතල</string> <string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string> <string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
<string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string> <string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_entries">චන්ද්‍රිකා: %s</string> <string name="prefs_data_entries">චන්ද්‍රිකා: %s</string>
@@ -110,46 +114,57 @@
<string name="prefs_data_clear">පිරිසිදු ක°</string> <string name="prefs_data_clear">පිරිසිදු ක°</string>
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string> <string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string> <string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_import_satellites_error">චන්ද්‍රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_sources_sat_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_transceivers_title">සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string> <string name="prefs_data_sources_sat_hint">ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම්</string>
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string> <string name="prefs_data_sources_transceivers_hint">ඉහළ ප්‍රමුඛතාව: සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string> <string name="prefs_data_sources_tle_switch">TLE URL</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_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">දත්ත ප්‍රතිදානය</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">ජාලය</string>
<string name="prefs_bt_output">Bluetooth</string> <string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
<string name="rc_settings_title">CAT පාලනය</string>
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
<string name="rc_enable_switch">CAT සබල කරන්න</string>
<string name="prefs_net_title">ජාල ප්‍රතිදානය</string> <string name="prefs_net_title">ජාල ප්‍රතිදානය</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="prefs_net_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string> <string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string> <string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string> <string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string> <string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string> <string name="prefs_bt_title">Bluetooth ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string> <string name="prefs_bt_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_rotator_device_hint">උපාංග id</string> <string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string> <string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string> <string name="prefs_bt_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_frequency_device_hint">උපාංග id</string> <string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string> <string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string> <string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string>
<string name="prefs_net_perm_error">Network අවසර පරික්‍ෂා කරන්න</string> <string name="prefs_net_perm_error">Network අවසර පරික්‍ෂා කරන්න</string>
<string name="prefs_other_title">වෙනත් සැකසුම්</string> <string name="prefs_other_title">වෙනත්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string> <string name="prefs_other_switch_utc">UTC වේලාව</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string> <string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string> <string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string> <string name="prefs_other_switch_sensors">සංවේදක</string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string> <string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string> <string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string> <string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -14,19 +14,23 @@
<string name="nav_prefs">Ayarlar</string> <string name="nav_prefs">Ayarlar</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string> <string name="sat_type_hint">Modlar: %s</string>
<string name="sat_type_title">Uydu türü seçin</string> <string name="sat_type_title">Modları seçin</string>
<string name="sat_search_hint">Id - Ad</string> <string name="sat_search_hint">Id - Ad</string>
<string name="sat_search_clear">Temizle</string> <string name="sat_search_clear">Temizle</string>
<string name="sat_clear_all">Tümünü temizle</string> <string name="sat_clear_all">Tümünü temizle</string>
<string name="sat_select_all">Tümünü seç</string> <string name="sat_select_all">Tümünü seç</string>
<string name="sat_group_selected">Seçilenler</string>
<string name="sat_group_available">Kullanılabilir</string>
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string> <string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
<string name="sat_warning_title">Uyarı\!</string> <string name="sat_warning_title">Uyarı\!</string>
<string name="sat_warning_message"> <string name="sat_warning_message">
Bu uygulamada 9000\'den fazla uydu listelenmiştir. Bu uygulamada 9000\'den fazla uydu listelenmiştir.
Hepsini aynı anda takip etmek anlamsızdır. Hepsini aynı anda takip etmek anlamsızdır.
\n\nListeyi yalnızca ilgilendiğiniz uydularla \n\nListeyi yalnızca ilgilendiğiniz uydularla
daraltmak için arama ve tür seçiciyi kullanın.</string> daraltmak için arama ve mod seçiciyi kullanın.</string>
<!-- Passes screen --> <!-- Passes screen -->
<string name="pass_filter_title">Geçişleri filtrele</string> <string name="pass_filter_title">Geçişleri filtrele</string>
@@ -36,7 +40,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 &gt;225dk)</string> <string name="pass_filter_deep_space">DeepSpace (periyot &gt;225dk)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string> <string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Modulasyon türü seçin</string> <string name="pass_modes_title">Modları seçin</string>
<string name="pass_satId" translatable="false">%05d</string> <string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Yükseklik açısı: %.1f°</string> <string name="pass_elevation">Yükseklik açısı: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string> <string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -127,11 +131,11 @@
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string> <string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
<string name="prefs_loc_success">Konum başarıyla güncellendi</string> <string name="prefs_loc_success">Konum başarıyla güncellendi</string>
<string name="prefs_station_title">İstasyon konumu ayarları</string> <string name="prefs_station_title">İstasyon konumu</string>
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string> <string name="prefs_station_lat_text">İstasyon enlemi</string>
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string> <string name="prefs_station_lon_text">İstasyon boylamı</string>
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string> <string name="prefs_locator_title">QTH konumlandırıcı</string>
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string> <string name="prefs_locator_text">Konumu lokatörle ayarla</string>
<string name="prefs_data_title">Uydu verisi</string> <string name="prefs_data_title">Uydu verisi</string>
<string name="prefs_data_entries">Uydular: %s</string> <string name="prefs_data_entries">Uydular: %s</string>
@@ -141,34 +145,35 @@
<string name="prefs_data_clear">Temizle</string> <string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string> <string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string> <string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_import_satellites_error">Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin.</string>
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string> <string name="prefs_data_sources_title">Özel kaynaklar</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_sources_sat_title">Uydu verileri</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string> <string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string> <string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string> <string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</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">TLE URL</string>
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string> <string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Veri aktarımı</string> <string name="prefs_data_output_title">Veri çıkışı</string>
<string name="prefs_net_output">Ağ</string> <string name="prefs_net_output">Ağ</string>
<string name="prefs_bt_output">Bluetooth</string> <string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string> <string name="prefs_cat_output">CAT</string>
<!-- Radio Control --> <!-- Radio Control -->
<string name="nav_radiocontrol">Radyo Kontrolü</string> <string name="nav_radiocontrol">Radyo Kontrolü</string>
<string name="rc_settings_title">CAT Radyo Kontrolü</string> <string name="rc_settings_title">CAT kontrolü</string>
<string name="rc_radio_model">Radyo Modeli</string> <string name="rc_radio_model">Radyo Modeli</string>
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string> <string name="rc_tx_device_hint">TX BT adresi</string>
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string> <string name="rc_rx_device_hint">RX BT adresi</string>
<string name="rc_tx_name_hint">TX Radyo Adı</string> <string name="rc_tx_name_hint">TX Radyo Adı</string>
<string name="rc_rx_name_hint">RX Radyo Adı</string> <string name="rc_rx_name_hint">RX Radyo Adı</string>
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string> <string name="rc_enable_switch">CAT\'i etkinleştir</string>
<string name="prefs_net_title">Ağ veri çıkışı</string> <string name="prefs_net_title">Ağ çıkışı</string>
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string> <string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string> <string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Biçim</string> <string name="prefs_net_rotator_format_hint">Biçim</string>
@@ -176,22 +181,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 veri çıkışı</string> <string name="prefs_bt_title">Bluetooth çıkışı</string>
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string> <string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string> <string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string> <string name="prefs_bt_rotator_output_hint">Biçim</string>
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string> <string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string> <string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string> <string name="prefs_bt_frequency_output_hint">Biçim</string>
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string> <string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string> <string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="prefs_other_title">Diğer ayarlar</string> <string name="prefs_other_title">Diğer</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string> <string name="prefs_other_switch_utc">UTC saati</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string> <string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string> <string name="prefs_other_switch_sweep">Radar taraması</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string> <string name="prefs_other_switch_sensors">Sensör</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string> <string name="prefs_other_switch_night_mode">Gece filtresi</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string> <string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string> <string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
@@ -205,8 +210,7 @@
\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) \n• Libre Space Foundation (SatNOGS)</string>
\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,12 +13,16 @@
<string name="nav_prefs">Налаштування</string> <string name="nav_prefs">Налаштування</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string> <string name="sat_type_hint">Режими: %s</string>
<string name="sat_type_title">Виберіть тип супутника</string> <string name="sat_type_title">Виберіть режими</string>
<string name="sat_search_hint">Id - Назва</string> <string name="sat_search_hint">Id - Назва</string>
<string name="sat_search_clear">Очистити</string> <string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string> <string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string> <string name="sat_select_all">Вибрати всі</string>
<string name="sat_group_selected">Обрані</string>
<string name="sat_group_available">Доступні</string>
<string name="sat_group_selected_count">Обрані (%1$d)</string>
<string name="sat_group_available_count">Доступні (%1$d)</string>
<string name="sat_empty_list_message"> <string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string> Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string> <string name="sat_warning_title">Увага\!</string>
@@ -26,7 +30,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>
@@ -35,7 +39,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 (період &gt;225хв)</string> <string name="pass_filter_deep_space">DeepSpace (період &gt;225хв)</string>
<string name="pass_modes_title">Виберіть тип модуляції</string> <string name="pass_modes_title">Виберіть режими</string>
<string name="pass_elevation">Піднес.: %.1f°</string> <string name="pass_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">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string> <string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
<string name="prefs_loc_success">Позицію успішно оновлено</string> <string name="prefs_loc_success">Позицію успішно оновлено</string>
<string name="prefs_station_title">Налаштування місцезнаходження станції</string> <string name="prefs_station_title">Позиція станції</string>
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string> <string name="prefs_station_lat_text">Широта станції</string>
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string> <string name="prefs_station_lon_text">Довгота станції</string>
<string name="prefs_locator_title">Налаштування QTH локатора</string> <string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Задати позицію за QTH локатором</string> <string name="prefs_locator_text">Позиція за локатором</string>
<string name="prefs_data_title">Супутникові дані</string> <string name="prefs_data_title">Супутникові дані</string>
<string name="prefs_data_entries">Супутників: %s</string> <string name="prefs_data_entries">Супутників: %s</string>
@@ -110,46 +114,57 @@
<string name="prefs_data_clear">Очистити</string> <string name="prefs_data_clear">Очистити</string>
<string name="prefs_data_clear_success">Дані успішно очищено</string> <string name="prefs_data_clear_success">Дані успішно очищено</string>
<string name="prefs_data_update_success">Оновлення успішне</string> <string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_sources_sat_title">Дані супутників</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string> <string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string> <string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string> <string name="prefs_data_sources_tle_switch">URL TLE</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_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">Вивід даних</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">Мережа</string>
<string name="prefs_bt_output">Bluetooth</string> <string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вивід мережевих даних</string> <string name="nav_radiocontrol">Керування радіо</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="rc_settings_title">CAT керування</string>
<string name="rc_radio_model">Модель радіо</string>
<string name="rc_tx_device_hint">BT адреса TX</string>
<string name="rc_rx_device_hint">BT адреса RX</string>
<string name="rc_tx_name_hint">Назва радіо TX</string>
<string name="rc_rx_name_hint">Назва радіо RX</string>
<string name="rc_enable_switch">Увімкнути CAT</string>
<string name="prefs_net_title">Мережевий вивід</string>
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string> <string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</string> <string name="prefs_net_rotator_format_hint">Формат даних</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_net_frequency_address_hint">IP:Порт</string> <string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string> <string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string> <string name="prefs_bt_title">Вивід Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string> <string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_bt_rotator_device_hint">Id пристрою</string> <string name="prefs_bt_rotator_device_hint">ID пристрою</string>
<string name="prefs_bt_rotator_output_hint">Формат даних</string> <string name="prefs_bt_rotator_output_hint">Формат даних</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string> <string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_bt_frequency_device_hint">Id пристрою</string> <string name="prefs_bt_frequency_device_hint">ID пристрою</string>
<string name="prefs_bt_frequency_output_hint">Формат даних</string> <string name="prefs_bt_frequency_output_hint">Формат даних</string>
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string> <string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string> <string name="prefs_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">Низько &lt; %1$d°</string> <string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -9,36 +9,9 @@
<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 -->
<string name="sat_type_hint">类型:%s</string>
<string name="sat_type_title">选择卫星类型</string>
<string name="sat_search_hint">Id - 名称</string>
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
<string name="pass_modes_title">选择调制类型</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen --> <!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string> <string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string> <string name="amsat_title">AMSAT 卫星状态</string>
@@ -53,6 +26,10 @@
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string> <string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
<string name="amsat_no_reports">此时间段无报告</string> <string name="amsat_no_reports">此时间段无报告</string>
<string name="amsat_close">关闭</string> <string name="amsat_close">关闭</string>
<string name="amsat_status_heard">已听到</string>
<string name="amsat_status_tlm_only">仅遥测</string>
<string name="amsat_status_not_heard">未听到</string>
<string name="amsat_status_crew_active">乘组活跃</string>
<string name="amsat_upload">上传</string> <string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string> <string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string> <string name="amsat_upload_report">上传报告到 AMSAT</string>
@@ -74,6 +51,36 @@
<string name="amsat_upload_not_heard">未听到</string> <string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string> <string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Satellites screen -->
<string name="sat_type_hint">模式:%s</string>
<string name="sat_type_title">选择模式</string>
<string name="sat_search_hint">Id - 名称</string>
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_group_selected">已选择</string>
<string name="sat_group_available">可用</string>
<string name="sat_group_selected_count">已选择(%1$d)</string>
<string name="sat_group_available_count">可用(%1$d)</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
<string name="pass_modes_title">选择模式</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">后退</string> <string name="radar_back">后退</string>
<string name="radar_notify">通知</string> <string name="radar_notify">通知</string>
@@ -92,12 +99,27 @@
<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>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">连接</string>
<string name="radar_disconnect">断开连接</string>
<string name="radar_off">关</string>
<string name="radar_stop">停止</string>
<string name="radar_track">跟踪</string>
<string name="radar_tx_base">TX 基频:</string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">授予权限</string>
<string name="sstv_listening">正在监听…</string>
<string name="sstv_mic_permission">需要麦克风权限</string>
<string name="sstv_mode_format">模式:%s</string>
<string name="sstv_mode_title">SSTV 模式</string>
<string name="sstv_no_signal">无信号</string>
<string name="sstv_no_transceiver">未选择收发器</string>
<string name="sstv_rx_format">RX:%s Hz</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">上一个</string> <string name="map_prev">上一个</string>
@@ -119,6 +141,12 @@
<string name="prefs_donate_title">支持</string> <string name="prefs_donate_title">支持</string>
<string name="prefs_privacy_title">隐私政策</string> <string name="prefs_privacy_title">隐私政策</string>
<string name="settings_paired_devices">已配对的蓝牙设备:</string>
<string name="settings_split_mode">分频模式(仅 IC-705)</string>
<string name="prefs_lat_prefix">纬度:%s°</string>
<string name="prefs_lon_prefix">经度:%s°</string>
<string name="prefs_qth_prefix">QTH:%s</string>
<string name="prefs_updated_title">更新:%s</string> <string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string> <string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
@@ -130,11 +158,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>
@@ -144,33 +172,42 @@
<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_restore">恢复默认源</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string> <string name="prefs_data_sources_sat_title">卫星数据</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_transceivers_title">收发器数据</string>
<string name="prefs_data_sources_satellites_label">卫星数据</string> <string name="prefs_data_sources_sat_hint">靠前优先:卫星名称</string>
<string name="prefs_data_sources_transceivers_label">收发器数据</string> <string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">数据输出</string> <string name="prefs_data_output_title">数据输出</string>
<string name="prefs_net_output">网络</string> <string name="prefs_net_output">网络</string>
<string name="prefs_bt_output">蓝牙</string> <string name="prefs_bt_output">蓝牙</string>
<string name="prefs_cat_output">CAT</string> <string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">网络数据输出</string> <string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
<string name="rc_tx_name_hint">TX 电台名称</string>
<string name="rc_rx_name_hint">RX 电台名称</string>
<string name="rc_enable_switch">启用 CAT</string>
<string name="prefs_net_title">网络输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string> <string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_address_hint">IP:端口</string> <string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string> <string name="prefs_net_rotator_format_hint">数据格式</string>
<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>
@@ -180,22 +217,14 @@
<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>
<!-- Radio Control --> <string name="prefs_other_title">其他</string>
<string name="nav_radiocontrol">电台控制</string> <string name="prefs_other_switch_utc">UTC 时间</string>
<string name="rc_settings_title">CAT 电台控制</string> <string name="prefs_other_switch_update">自动更新数据</string>
<string name="rc_radio_model">电台型号</string> <string name="prefs_other_switch_sweep">雷达扫描动画</string>
<string name="rc_tx_device_hint">发射电台蓝牙地址</string> <string name="prefs_other_switch_sensors">传感器控制雷达</string>
<string name="rc_rx_device_hint">接收电台蓝牙地址</string> <string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="rc_tx_name_hint">发射电台名称</string> <string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
<string name="rc_rx_name_hint">接收电台名称</string> <string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</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">低 &lt; %1$d°</string> <string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -209,41 +238,10 @@
\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) \n• Libre Space Foundation (SatNOGS)</string>
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA(CW解码功能)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- 匹配页 Mutual match page --> <!-- Main screen -->
<string name="mutual_title">过境匹配</string> <string name="tracking_format">跟踪:%s</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,24 +10,28 @@
<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">Type: %s</string> <string name="sat_type_hint">Modes: %s</string>
<string name="sat_type_title">Select satellite type</string> <string name="sat_type_title">Select modes</string>
<string name="sat_search_hint">Id - Name</string> <string name="sat_search_hint">Id - Name</string>
<string name="sat_search_clear">Clear</string> <string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string> <string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string> <string name="sat_select_all">Select all</string>
<string name="sat_group_selected">Selected</string>
<string name="sat_group_available">Available</string>
<string name="sat_group_selected_count">Selected (%1$d)</string>
<string name="sat_group_available_count">Available (%1$d)</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string> <string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string> <string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message"> <string name="sat_warning_message">
There are over 9000 satellites listed in this app. There are over 9000 satellites listed in this app.
It makes no sense to track them all at the same time. It makes no sense to track them all at the same time.
\n\nAlways try to narrow down the list to only the ones \n\nAlways try to narrow down the list to only the ones
you\'re interested in via search and types selector.</string> you\'re interested in via search and the modes selector.</string>
<!-- Passes screen --> <!-- Passes screen -->
<string name="pass_filter_title">Filter passes</string> <string name="pass_filter_title">Filter passes</string>
@@ -37,7 +41,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 modulation type</string> <string name="pass_modes_title">Select modes</string>
<string name="pass_satId" translatable="false">%05d</string> <string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevation: %.1f°</string> <string name="pass_elevation">Elevation: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string> <string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -52,8 +56,14 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Added required tweaks to support Android 17 (API 37) * Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output \n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
\nSources now have priority - the top ones define satellite names and transceiver data.
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
</string> </string>
<!-- AMSAT Status screen --> <!-- AMSAT Status screen -->
@@ -70,6 +80,10 @@
<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_status_heard">Heard</string>
<string name="amsat_status_tlm_only">Telemetry Only</string>
<string name="amsat_status_not_heard">Not Heard</string>
<string name="amsat_status_crew_active">Crew Active</string>
<string name="amsat_upload">Upload</string> <string name="amsat_upload">Upload</string>
<string name="amsat_upload_back">Back</string> <string name="amsat_upload_back">Back</string>
<string name="amsat_upload_report">Upload report to AMSAT</string> <string name="amsat_upload_report">Upload report to AMSAT</string>
@@ -116,11 +130,27 @@
<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>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">Connect</string>
<string name="radar_disconnect">Disconnect</string>
<string name="radar_off">Off</string>
<string name="radar_stop">Stop</string>
<string name="radar_track">Track</string>
<string name="radar_tx_base">TX Base: </string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">Grant permission</string>
<string name="sstv_listening">Listening…</string>
<string name="sstv_mic_permission">Microphone access needed</string>
<string name="sstv_mode_format">Mode: %s</string>
<string name="sstv_mode_title">SSTV Mode</string>
<string name="sstv_no_signal">No signal</string>
<string name="sstv_no_transceiver">No transceiver selected</string>
<string name="sstv_rx_format">RX: %s Hz</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">Prev</string> <string name="map_prev">Prev</string>
@@ -154,6 +184,12 @@
<string name="prefs_privacy_title">Privacy Policy</string> <string name="prefs_privacy_title">Privacy Policy</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string> <string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="settings_paired_devices">Paired Bluetooth Devices:</string>
<string name="settings_split_mode">Split mode (IC-705 only)</string>
<string name="prefs_lat_prefix">Lat: %s°</string>
<string name="prefs_lon_prefix">Lon: %s°</string>
<string name="prefs_qth_prefix">Qth: %s</string>
<string name="prefs_updated_title">Updated: %s</string> <string name="prefs_updated_title">Updated: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string> <string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -165,11 +201,11 @@
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Invalid QTH locator</string> <string name="prefs_loc_qth_error">Invalid QTH locator</string>
<string name="prefs_loc_success">Position updated successfully</string> <string name="prefs_loc_success">Position updated successfully</string>
<string name="prefs_station_title">Station position settings</string> <string name="prefs_station_title">Station position</string>
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string> <string name="prefs_station_lat_text">Station latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string> <string name="prefs_station_lon_text">Station longitude</string>
<string name="prefs_locator_title">QTH locator settings</string> <string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">Set station\'s position using locator</string> <string name="prefs_locator_text">Set position via locator</string>
<string name="prefs_data_title">Satellite data</string> <string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string> <string name="prefs_data_entries">Satellites: %s</string>
@@ -182,12 +218,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">Custom data sources</string> <string name="prefs_data_sources_title">Data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string> <string name="prefs_data_sources_restore">Restore default sources</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string> <string name="prefs_data_sources_sat_title">Satellites data</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_transceivers_title">Transceivers data</string>
<string name="prefs_data_sources_satellites_label">Satellites data</string> <string name="prefs_data_sources_sat_hint">Top priority affects satellite names</string>
<string name="prefs_data_sources_transceivers_label">Transceivers data</string> <string name="prefs_data_sources_transceivers_hint">Top priority affects 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>
@@ -196,15 +232,15 @@
<!-- Radio Control --> <!-- Radio Control -->
<string name="nav_radiocontrol">Radio Control</string> <string name="nav_radiocontrol">Radio Control</string>
<string name="rc_settings_title">CAT Radio Control</string> <string name="rc_settings_title">CAT control</string>
<string name="rc_radio_model">Radio Model</string> <string name="rc_radio_model">Radio Model</string>
<string name="rc_tx_device_hint">TX Radio BT Address</string> <string name="rc_tx_device_hint">TX BT address</string>
<string name="rc_rx_device_hint">RX Radio BT Address</string> <string name="rc_rx_device_hint">RX BT address</string>
<string name="rc_tx_name_hint">TX Radio Name</string> <string name="rc_tx_name_hint">TX Radio Name</string>
<string name="rc_rx_name_hint">RX Radio Name</string> <string name="rc_rx_name_hint">RX Radio Name</string>
<string name="rc_enable_switch">Enable CAT Control</string> <string name="rc_enable_switch">Enable CAT</string>
<string name="prefs_net_title">Network data output</string> <string name="prefs_net_title">Network output</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string> <string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string> <string name="prefs_net_rotator_format_hint">Format</string>
@@ -214,22 +250,24 @@
<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 data output</string> <string name="prefs_bt_title">Bluetooth output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string> <string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Device id</string> <string name="prefs_bt_rotator_device_hint">Device ID</string>
<string name="prefs_bt_rotator_output_hint">Data format</string> <string name="prefs_bt_rotator_output_hint">Format</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string> <string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Device id</string> <string name="prefs_bt_frequency_device_hint">Device ID</string>
<string name="prefs_bt_frequency_output_hint">Data format</string> <string name="prefs_bt_frequency_output_hint">Format</string>
<string name="prefs_bt_perm_error">Check your bluetooth permission</string> <string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_net_perm_error">Check your network permission</string> <string name="prefs_net_perm_error">Check your network permission</string>
<string name="prefs_other_title">Other settings</string> <string name="prefs_other_title">Other</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string> <string name="prefs_other_switch_utc">UTC time</string>
<string name="prefs_other_switch_update">Enable automatic data update</string> <string name="prefs_other_switch_update">Auto-update data</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string> <string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string> <string name="prefs_other_switch_sensors">Use sensors</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string> <string name="prefs_other_switch_night_mode">Night filter</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_highlight_title">Elevation highlight</string> <string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string> <string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -245,40 +283,10 @@
\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 --> <!-- Main screen -->
<string name="mutual_title">Pass Match</string> <string name="tracking_format">Tracking: %s</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,2 +1,5 @@
* Added required tweaks to support Android 17 (API 37) * Tweaked sun calculations, added tests by atsunatsu (#241)
* Added ACCESS_LOCAL_NETWORK permission check to support network data output * Overhauled sources import, added HTTP status by wty2019wty (#242)
* Localized AMSAT status screen for Chinese by atsunatsu (#245)
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
* Added AMSAT satellite status reports submission by atsunatsu (#248)
@@ -21,6 +21,7 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay import org.osmdroid.views.overlay.Overlay
import kotlin.math.cos import kotlin.math.cos
@@ -39,9 +40,11 @@ import kotlin.math.sin
* exceeds 90°, i.e. the dot product of the two unit vectors is negative: * exceeds 90°, i.e. the dot product of the two unit vectors is negative:
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0 * dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
* *
* Performance: we sample one column per [stepPx] pixels (default 4) and draw * Performance: we sample one column per stepPx pixels (default 4) and draw
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig * filled vertical rectangles. On a 1080-wide screen this means ~270 trig
* evaluations per row, which is imperceptible. * evaluations per row, which is imperceptible. draw() is called on every
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
* single GeoPoint instance.
*/ */
class MapNightOverlay : Overlay() { class MapNightOverlay : Overlay() {
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
private val rect = RectF() private val rect = RectF()
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return if (shadow) return
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
val h = mapView.height val h = mapView.height
val stepPx = 4 // sample every N pixels — balance quality vs CPU val stepPx = 4 // sample every N pixels — balance quality vs CPU
// The map is never rotated, so latitude depends only on y and longitude only on x.
// Resolve the top/bottom latitudes once instead of once per column.
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
val sinLatTop = sin(latTopRad)
val cosLatTop = cos(latTopRad)
val sinLatBot = sin(latBotRad)
val cosLatBot = cos(latBotRad)
// We scan column by column. For each column we determine the longitude, // We scan column by column. For each column we determine the longitude,
// then find the latitude range that is in night and shade it. // then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night // Since longitude is constant along a vertical strip and the day/night
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
var x = 0 var x = 0
while (x < w) { while (x < w) {
// Get the geographic coordinate at the top and bottom of this column. val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
val lonRad = Math.toRadians(geoTop.longitude) val lonRad = Math.toRadians(geoTop.longitude)
val cosLonDiff = cos(lonRad - sunLonRad) val cosLonDiff = cos(lonRad - sunLonRad)
// Top pixel geographic lat
val latTopRad = Math.toRadians(geoTop.latitude)
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
val latBotRad = Math.toRadians(geoBot.latitude)
// dot(sunVec, pointVec) < 0 → night // dot(sunVec, pointVec) < 0 → night
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff // dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
when { when {
dotTop < 0 && dotBot < 0 -> { dotTop < 0 && dotBot < 0 -> {
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
var hi = yBot var hi = yBot
while (hi - lo > 1) { while (hi - lo > 1) {
val mid = (lo + hi) / 2 val mid = (lo + hi) / 2
val geo = proj.fromPixels(x, mid) ?: return mid val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
val latRad = Math.toRadians(geo.latitude) val latRad = Math.toRadians(geo.latitude)
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid if (dot < 0) hi = mid else lo = mid
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
@@ -71,6 +70,10 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.XYTileSource import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
@@ -126,6 +129,21 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container)) val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView) MapScreen(uiState, viewModel::onAction, mapView)
} }
@@ -283,19 +301,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private val markerPool = HashMap<String, Marker>() private val markerPool = HashMap<String, Marker>()
private var lastMapView: MapView? = null private var lastMapView: MapView? = null
/**
* Above this many satellites inside the viewport labels are dropped in favor of a single
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
*/
private const val LABEL_LIMIT = 128
/** Degrees of space around the viewport so markers don't pop in at the edges */
private const val VIEWPORT_MARGIN = 8.0
/** Debounce for viewport-driven marker refreshes, in milliseconds */
private const val MAP_LISTENER_DELAY = 128L
/** Shared dot icon used when too many satellites are visible to label them */
private var dotIcon: Drawable? = null
/** Scratch list reused every frame to avoid per-tick allocation */
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
private var lastAction: ((OrbitalObject) -> Unit)? = null
private fun setPositions( private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>, posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView, mapView: MapView,
action: (OrbitalObject) -> Unit action: (OrbitalObject) -> Unit
) { ) {
try { try {
lastPositions = posMap
lastAction = action
// Clear caches when the MapView instance changes (e.g. config change) // Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) { if (lastMapView !== mapView) {
lastMapView = mapView lastMapView = mapView
markerPool.clear() markerPool.clear()
iconCache.evictAll() iconCache.evictAll()
dotIcon = null
footprintPolyline = null footprintPolyline = null
footprintPoints = null footprintPoints = null
mapView.addMapListener(DelayedMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
}, MAP_LISTENER_DELAY))
} }
// Reuse the existing FolderOverlay — creating a new one and replacing it // Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible. // causes osmdroid to detach shared Marker objects, making them invisible.
@@ -304,17 +352,48 @@ private fun setPositions(
} }
folder.items.clear() folder.items.clear()
val activeNames = HashSet<String>(posMap.size) // Cull satellites outside the viewport: only meaningful once zoomed in, but that is
posMap.forEach { (satellite, geoPos) -> // exactly when marker labels are shown and drawing is most expensive.
visibleSats.clear()
if (mapView.width > 0 && mapView.height > 0) {
val box = mapView.boundingBox
val latNorth = box.latNorth + VIEWPORT_MARGIN
val latSouth = box.latSouth - VIEWPORT_MARGIN
val lonWest = box.lonWest - VIEWPORT_MARGIN
val lonEast = box.lonEast + VIEWPORT_MARGIN
val wrapsDateLine = box.lonWest > box.lonEast
for ((satellite, geoPos) in posMap) {
val lat = geoPos.latitude
if (lat !in latSouth..latNorth) continue
val lon = geoPos.longitude
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
else lon in lonWest..lonEast
if (isLonVisible) visibleSats.add(satellite to geoPos)
}
} else {
for (entry in posMap) visibleSats.add(entry.key to entry.value)
}
val showLabels = visibleSats.size <= LABEL_LIMIT
val activeNames = HashSet<String>(visibleSats.size)
for ((satellite, geoPos) in visibleSats) {
val name = satellite.data.name val name = satellite.data.name
activeNames.add(name) activeNames.add(name)
val marker = markerPool.getOrPut(name) { val marker = markerPool.getOrPut(name) {
Marker(mapView).apply { Marker(mapView).apply {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCachedTextIcon(name, mapView) // Resolve the satellite via relatedObject so the listener is allocated
// once per marker instead of once per satellite per tick
setOnMarkerClickListener { clicked, _ ->
(clicked.relatedObject as? OrbitalObject)?.let(action)
true
}
} }
} }
marker.relatedObject = satellite
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
if (marker.icon !== icon) marker.icon = icon
// Update position in-place — reuse existing GeoPoint if available // Update position in-place — reuse existing GeoPoint if available
val pos = marker.position val pos = marker.position
if (pos != null) { if (pos != null) {
@@ -323,22 +402,32 @@ private fun setPositions(
} else { } else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude) marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
} }
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker) folder.add(marker)
} }
// Evict markers for satellites no longer tracked // Evict markers that are no longer tracked or no longer visible
val iter = markerPool.keys.iterator() markerPool.keys.retainAll(activeNames)
while (iter.hasNext()) { visibleSats.clear()
if (iter.next() !in activeNames) iter.remove()
}
} catch (e: Exception) { } catch (e: Exception) {
println(e) println(e)
} }
} }
/** Re-applies the last known positions against the new viewport after a pan or zoom */
private fun refreshPositions(mapView: MapView): Boolean {
val posMap = lastPositions ?: return false
val action = lastAction ?: return false
setPositions(posMap, mapView, action)
mapView.invalidate()
return true
}
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
val size = 20
val bitmap = createBitmap(size, size)
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
}
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable { private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
iconCache[name]?.let { return it } iconCache[name]?.let { return it }
val labelRect = Rect() val labelRect = Rect()
@@ -376,9 +465,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
private var footprintPolyline: Polyline? = null private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList<GeoPoint>? = null private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
try { try {
val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) { if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size) pts = ArrayList(rangeCircle.size)
@@ -508,9 +596,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(lifecycleObserver) lifecycle.addObserver(lifecycleObserver)
onDispose { lifecycle.removeObserver(lifecycleObserver) } onDispose { lifecycle.removeObserver(lifecycleObserver) }
} }
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
DisposableEffect(mapView) {
onDispose {
clearMapCaches()
mapView.onDetach()
}
}
return mapView return mapView
} }
private fun clearMapCaches() {
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
lastPositions = null
lastAction = null
lastMapView = null
}
@Composable @Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) { private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.feature.map
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState( data class MapState(
val mapData: MapData? = null, val mapData: MapData? = null,
@@ -29,7 +28,7 @@ data class MapState(
val stationPosition: GeoPos? = null, val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass, val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null, val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null, val footprint: List<GeoPos>? = null,
val positions: Map<OrbitalObject, GeoPos>? = null, val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0, val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0, val sunLonDeg: Double = 0.0,
@@ -42,6 +41,7 @@ sealed interface MapAction {
data object SelectNext : MapAction data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction data class SelectDefaultItem(val catnum: Int) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
} }
data class MapData( data class MapData(
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -45,10 +46,12 @@ 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.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
class MapViewModel( class MapViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
@@ -69,6 +72,9 @@ class MapViewModel(
private var dataUpdateJob: Job? = null private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L private var dataUpdateRate = 1000L
private var selectedOrbitalObject: OrbitalObject? = null private var selectedOrbitalObject: OrbitalObject? = null
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
private val isScreenVisible = MutableStateFlow(true)
val uiState: StateFlow<MapState> = _uiState val uiState: StateFlow<MapState> = _uiState
init { init {
@@ -87,6 +93,7 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false) MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item) is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum) is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
} }
} }
@@ -124,7 +131,11 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject selectedOrbitalObject = orbitalObject
viewModelScope.launch { viewModelScope.launch {
dataUpdateJob?.cancelAndJoin() dataUpdateJob?.cancelAndJoin()
dataUpdateJob = launch { // Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
// satelliteRepo call internally hops to Default and resumes back on the caller's
// dispatcher. On Main that posts one continuation per satellite per tick, which
// floods the looper and ANRs for users tracking thousands of objects.
dataUpdateJob = launch(Dispatchers.Default) {
val dateNow = Date() val dateNow = Date()
getStationPosition() getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
@@ -135,9 +146,11 @@ class MapViewModel(
else -> updateFreq else -> updateFreq
} }
while (isActive) { while (isActive) {
// Suspends while the map is off-screen instead of predicting into the void
isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(effectiveRate) delay(effectiveRate.milliseconds)
} }
} }
} }
@@ -187,7 +200,8 @@ class MapViewModel(
// 2. Derive footprint, info data, sun and moon position from already-computed state // 2. Derive footprint, info data, sun and moon position from already-computed state
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time) val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
val footprint = satPos // Range circle is 721 trig-heavy points — keep it on this background dispatcher
val footprint = satPos.getRangeCircle()
val mapData = buildMapData(selected, satPos, date) val mapData = buildMapData(selected, satPos, date)
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time) val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time) val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
@@ -294,7 +308,7 @@ class MapViewModel(
companion object { companion object {
/** Number of parallel chunks for satellite position computation */ /** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4 private val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
-7
View File
@@ -1,7 +0,0 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.mutual"
}
@@ -1,264 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.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
)
}
@@ -1,53 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
/**
* A mutual pass where a satellite is visible from two stations simultaneously.
*
* @param catNum Satellite catalog number
* @param name Satellite name
* @param startTime Common AOS time (millis)
* @param endTime Common LOS time (millis)
* @param maxElevationA Peak elevation for station A
* @param maxElevationB Peak elevation for station B
* @param elevationSamples List of (time, (elevationA, elevationB)) pairs
* @param trackSamples List of radar-track samples (azimuth/elevation for both stations, degrees)
*/
data class MutualPass(
val catNum: Int,
val name: String,
val startTime: Long,
val endTime: Long,
val maxElevationA: Double,
val maxElevationB: Double,
val elevationSamples: List<Pair<Long, Pair<Double, Double>>>,
val trackSamples: List<TrackSample> = emptyList()
)
/**
* One radar-track sample: satellite position as seen from both stations.
* All angles are in degrees.
*/
data class TrackSample(
val time: Long,
val azimuthA: Double,
val elevationA: Double,
val azimuthB: Double,
val elevationB: Double
)
@@ -1,223 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
private const val CIRCLES = 3 // 30°, 60°, 90° rings
private const val STROKE_WIDTH = 2.5f
/**
* Polar radar chart showing the satellite track as seen from both stations
* on one plot. Station A is drawn solid, station B dashed.
* Controlled: [progress] (0..1) and [onProgressChange] are owned by the parent
* so the time cursor can be shared with the elevation curve chart.
*/
@Composable
fun MutualRadarView(
trackSamples: List<TrackSample>,
progress: Float = 0.5f,
labelA: String = "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()
)
}
@@ -1,620 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.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))
}
}
}
}
}
}
@@ -1,569 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.roundToInt
data class MutualUiState(
val stationALat: String = "",
val stationALon: String = "",
val stationAGrid: String = "",
val stationAMinElev: Double = 10.0,
val stationBLat: String = "",
val stationBLon: String = "",
val stationBGrid: String = "",
val stationBMinElev: Double = 10.0,
val hoursAhead: Int = 24,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val 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,20 +58,13 @@ 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.SharedDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
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
@@ -147,7 +140,7 @@ internal fun PassesFilterDialog(
) )
cancel() cancel()
} }
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(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,
@@ -372,7 +365,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() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(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
@@ -381,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
horizontalArrangement = Arrangement.spacedBy(1.dp), horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp) verticalArrangement = Arrangement.spacedBy(1.dp)
) { ) {
itemsIndexed(allModes) { index, item -> itemsIndexed(Sources.satelliteModes) { index, item ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -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,23 +84,19 @@ import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@Composable @Composable
fun PassesDestination( fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
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, navigateToMap) PassesScreen(uiState, viewModel::onAction, navigateToRadar)
} }
@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(
@@ -139,26 +135,14 @@ private fun PassesScreen(
} }
if (uiState.shouldSeeWhatsNew) { if (uiState.shouldSeeWhatsNew) {
val dismiss = { onAction(PassesAction.DismissWhatsNew) } val dismiss = { onAction(PassesAction.DismissWhatsNew) }
SharedDialog( WhatsNewDialog(onDismiss = dismiss)
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.TogglePassesDialog) }, resId = R.drawable.ic_filter) IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
}, },
topInfo = { topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos) TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
@@ -167,7 +151,7 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc) NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
}, },
endAction = { endAction = {
IconCard(action = navigateToMap, resId = R.drawable.ic_map) IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
} }
) )
} }
@@ -294,6 +278,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
} }
} }
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun DeepSpacePassPreview() { private fun DeepSpacePassPreview() {
@@ -320,12 +309,13 @@ 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", Locale.getDefault()).also { it.timeZone = timeZone } SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
} }
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) } val 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)) }
@@ -345,6 +335,10 @@ 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
@@ -354,7 +348,7 @@ private fun PassItem(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.onSurface
) )
val elevColor = elevationColor(pass.maxElevation) val elevColor = elevationColor(pass.maxElevation)
Icon( Icon(
@@ -23,7 +23,6 @@ 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
@@ -35,14 +34,17 @@ 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,
@@ -62,7 +64,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.passesSettings.value.selectedModes, modes = settingsRepo.selectedSatModes.value,
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
) )
) )
@@ -88,33 +90,43 @@ class PassesViewModel(
} }
} }
} }
// Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch { viewModelScope.launch {
satelliteRepo.passes.collectLatest { allPasses -> settingsRepo.selectedSatModes.collectLatest { modes ->
while (isActive) { _uiState.update { it.copy(modes = modes) }
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
val grouped = groupPasses(processed, isUtc)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
sunTimes = sunTimes,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
}
} }
} }
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
// computations run once per restart, progress/countdown are calculated every second.
viewModelScope.launch {
combine(
satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
val filtered = if (showDeepSpace) allPasses
else allPasses.filter { !it.isDeepSpace }
// Expensive: recompute sun times once per items/UTC change, not every second
val sunTimes = computeSunTimes(filtered, isUtc)
_uiState.update { it.copy(sunTimes = sunTimes) }
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow)
val grouped = groupPasses(processed, isUtc)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000.milliseconds)
}
}
}
} }
fun onAction(action: PassesAction) { fun onAction(action: PassesAction) {
@@ -129,21 +141,9 @@ 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,30 +154,32 @@ class PassesViewModel(
} }
} }
/** Returns a locale-appropriate date format for pass grouping headers. */ private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
private fun dateFormat(tz: TimeZone): SimpleDateFormat { private fun dateFormat(tz: TimeZone): SimpleDateFormat {
val pattern = if (Locale.getDefault().language == "zh") { val locale = displayLocale()
val pattern = if (locale.language == Locale.CHINESE.language) {
"yyyy'年'M'月'd'日' EEEE" "yyyy'年'M'月'd'日' EEEE"
} else { } else {
"EEE, dd MMM yyyy" "EEE, dd MMM yyyy"
} }
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz } return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
} }
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace. // 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", Locale.getDefault()).also { it.timeZone = tz } val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>() val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times // DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) { if (passes.any { it.isDeepSpace }) {
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz)) val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
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
@@ -186,7 +188,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 = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz)) val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
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
@@ -194,43 +196,6 @@ 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)
@@ -288,8 +253,7 @@ 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(
@@ -298,8 +262,7 @@ 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) }
@@ -312,10 +275,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,
@@ -327,6 +290,20 @@ class PassesViewModel(
) )
} }
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
settingsRepo.setSelectedSatModes(modes)
_uiState.update { it.copy(modes = modes) }
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = _uiState.value.hours,
minElevation = _uiState.value.elevation,
aosStartMinute = _uiState.value.aosStartMinute,
aosEndMinute = _uiState.value.aosEndMinute,
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
modes = modes
)
}
private fun refreshPasses() = viewModelScope.launch { private fun refreshPasses() = viewModelScope.launch {
val settings = settingsRepo.passesSettings.value val settings = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses( satelliteRepo.calculatePasses(
@@ -336,7 +313,7 @@ class PassesViewModel(
aosStartMinute = settings.aosStartMinute, aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute, aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow, invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes modes = settingsRepo.selectedSatModes.value
) )
} }
+1 -1
View File
@@ -4,4 +4,4 @@ plugins {
android { android {
namespace = "com.rtbishop.look4sat.feature.radar" namespace = "com.rtbishop.look4sat.feature.radar"
} }
@@ -31,9 +31,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -60,7 +63,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.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
@@ -74,8 +76,6 @@ 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"),
@@ -84,35 +84,11 @@ private enum class RadarPage(val title: String) {
} }
@Composable @Composable
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) { fun RadarDestination(navigateUp: () -> 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
@@ -122,10 +98,8 @@ fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
} }
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) })
} }
@@ -135,38 +109,16 @@ 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()
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot val addToCalendar: () -> Unit = {
// at the current moment, same display mode as the local station. uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
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()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(), .keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
@@ -175,7 +127,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 = navigateToMap, resId = R.drawable.ic_map) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
} }
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) } TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else { } else {
@@ -183,15 +135,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 = navigateToMap, resId = R.drawable.ic_map) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
} }
} }
if (isVertical) { if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, 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, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} }
} }
@@ -250,8 +202,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,
calculatorOffsetKHz = uiState.calculatorOffsetKHz, onAction = onAction,
onAction = onAction calculatorOffsetKHz = uiState.calculatorOffsetKHz
) )
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
@@ -266,12 +218,7 @@ private fun PagerCard(
} }
@Composable @Composable
private fun RadarCard( private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
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
@@ -304,11 +251,10 @@ private fun RadarCard(
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,17 +58,12 @@ 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,8 +60,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.predict.PI_2 import com.rtbishop.look4sat.core.domain.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
@@ -73,12 +71,10 @@ 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,
@@ -112,53 +108,32 @@ 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 || trackB !== cachedTrackBRef) { if (radius != cachedRadius || items !== cachedItemsRef) {
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
} }
rotate(if (shouldUseCompass) -azimElev.first else 0f) { val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
rotate(rotation) {
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) } 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)
} }
@@ -233,25 +208,13 @@ 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())
// Split the path when azimuth wraps 0°/360° to avoid a line across the plot if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
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,11 +65,14 @@ class RadarViewModel(
private val showToast: IShowToast private val showToast: IShowToast
) : ViewModel() { ) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value private var stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos) private var 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
@@ -88,33 +91,67 @@ class RadarViewModel(
val uiState: StateFlow<RadarState> = _uiState val uiState: StateFlow<RadarState> = _uiState
init { init {
collectCompassSensor() collectSettingsChanges() // also handles initial sensor subscription
collectSettingsChanges() collectStationPositionChanges()
collectPassAndStartTickLoop() collectPassAndStartTickLoop()
collectRadioTrackingState() collectRadioTrackingState()
} }
private fun collectCompassSensor() { // Starts sensor collection if not already running.
if (!settingsRepo.otherSettings.value.stateOfSensors) return private fun startSensorCollection() {
viewModelScope.launch { if (sensorCollectionJob?.isActive == true) return
sensorCollectionJob = viewModelScope.launch {
sensorsRepo.enableSensor() sensorsRepo.enableSensor()
sensorsRepo.sensorData.collect { data -> sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second val orientationValues =
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
_uiState.update { it.copy(orientationValues = orientationValues) } _uiState.update { it.copy(orientationValues = orientationValues) }
} }
} }
} }
// Stops sensor collection and disables the hardware sensor.
private fun stopSensorCollection() {
sensorCollectionJob?.cancel()
sensorCollectionJob = null
sensorsRepo.disableSensor()
}
private fun collectSettingsChanges() { private fun collectSettingsChanges() {
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> settingsRepo.otherSettings.collect { settings ->
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) }
}
}
} }
} }
} }
@@ -136,8 +173,6 @@ 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()
} }
@@ -148,13 +183,7 @@ 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
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: "" _uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
_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) {
@@ -221,7 +250,7 @@ class RadarViewModel(
} }
override fun onCleared() { override fun onCleared() {
sensorsRepo.disableSensor() stopSensorCollection()
} }
fun onAction(action: RadarAction) { fun onAction(action: RadarAction) {
@@ -231,6 +260,7 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value // Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid val newUuid = if (isTogglingOff) null else action.uuid
// Load the saved offset for the newly selected satellite
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) } ?: ""
@@ -289,7 +319,6 @@ class RadarViewModel(
sstvDecoder?.clearPixels() sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
} }
is RadarAction.ChangeCalculatorOffset -> { 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
@@ -426,7 +455,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.40f // Changed from 0.25 to safer default private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs. // 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
@@ -449,7 +478,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.Look4SatLimited private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
// 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
@@ -461,7 +490,6 @@ 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(
@@ -49,14 +49,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
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.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
@@ -78,12 +79,12 @@ internal fun SstvPage(
) { ) {
Text(text = "🎙️", fontSize = 48.sp) Text(text = "🎙️", fontSize = 48.sp)
Text( Text(
text = "Microphone access needed", text = stringResource(R.string.sstv_mic_permission),
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Button(onClick = requestMicPermission) { Button(onClick = requestMicPermission) {
Text("Grant permission") Text(stringResource(R.string.sstv_grant_permission))
} }
} }
} }
@@ -96,14 +97,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 }
SharedDialog( ConfirmDialog(
title = "SSTV Mode", title = stringResource(R.string.sstv_mode_title),
onDismissRequest = dismiss, onAccept = dismiss,
onAccept = dismiss onCancel = dismiss
) { _ -> ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxHeight(0.7f) .fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp) verticalArrangement = Arrangement.spacedBy(1.dp)
) { ) {
@@ -167,7 +168,7 @@ internal fun SstvPage(
) )
} else { } else {
Text( Text(
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal", text = if (sstv.status == SstvStatus.Recording) stringResource(R.string.sstv_listening) else stringResource(R.string.sstv_no_signal),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
@@ -196,7 +197,7 @@ internal fun SstvPage(
) { ) {
// Doppler-corrected downlink frequency hint // Doppler-corrected downlink frequency hint
OutlinedText( OutlinedText(
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected", text = dopplerFrequency?.let { stringResource(R.string.sstv_rx_format, it) } ?: stringResource(R.string.sstv_no_transceiver),
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fillColor = MaterialTheme.colorScheme.primary, fillColor = MaterialTheme.colorScheme.primary,
@@ -227,7 +228,7 @@ internal fun SstvPage(
) { ) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
text = "Mode: ${sstv.selectedMode}", text = stringResource(R.string.sstv_mode_format, sstv.selectedMode),
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@@ -24,14 +24,12 @@ 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
@@ -45,7 +43,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.layout.PaddingValues import androidx.compose.foundation.text.BasicTextField
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
@@ -55,23 +53,17 @@ 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
@@ -85,6 +77,7 @@ 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
@@ -134,8 +127,8 @@ fun CalculatorPage(
transceivers: List<SatRadio>, transceivers: List<SatRadio>,
selectedUuid: String?, selectedUuid: String?,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
calculatorOffsetKHz: String = "",
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
calculatorOffsetKHz: String = "",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val calculatorTransceivers = remember(transceivers) { val calculatorTransceivers = remember(transceivers) {
@@ -152,44 +145,47 @@ fun CalculatorPage(
return return
} }
Column( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()), .padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
if (calculatorTransceivers.size > 1) { if (calculatorTransceivers.size > 1) {
FlowRow( item {
horizontalArrangement = Arrangement.spacedBy(6.dp), FlowRow(
verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(4.dp),
) { modifier = Modifier.fillMaxWidth()
calculatorTransceivers.forEach { radio -> ) {
FilterChip( calculatorTransceivers.forEach { radio ->
selected = radio.uuid == selectedTransceiver.uuid, FilterChip(
onClick = { selected = radio.uuid == selectedTransceiver.uuid,
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid)) onClick = {
}, if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
label = { },
Text( label = {
text = transceiverTitle(radio), Text(
maxLines = 1, text = transceiverTitle(radio),
overflow = TextOverflow.Ellipsis maxLines = 1,
) overflow = TextOverflow.Ellipsis
} )
) }
)
}
} }
} }
} }
DopplerFrequencyCalculator( item {
transponder = selectedTransceiver, DopplerFrequencyCalculator(
orbitalPos = orbitalPos, transponder = selectedTransceiver,
offsetKHz = calculatorOffsetKHz, orbitalPos = orbitalPos,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) }, offsetKHz = calculatorOffsetKHz,
modifier = Modifier.fillMaxWidth() onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
) modifier = Modifier.fillMaxWidth()
)
}
} }
} }
@@ -261,7 +257,6 @@ 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,
@@ -382,7 +377,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
@@ -408,7 +403,7 @@ private fun ExpandedRadioControl(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = "TX Base: ", text = stringResource(R.string.radar_tx_base),
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -455,16 +450,17 @@ private fun ExpandedRadioControl(
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 5,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
val chipModifier = Modifier.width(64.dp) val chipModifier = Modifier.weight(1f)
FilterChip( FilterChip(
selected = radioControl.ctcssTone == null, selected = radioControl.ctcssTone == null,
onClick = { onAction(RadarAction.SetCtcssTone(null)) }, onClick = { onAction(RadarAction.SetCtcssTone(null)) },
label = { label = {
Text( Text(
text = "Off", text = stringResource(R.string.radar_off),
fontSize = 12.sp, fontSize = 12.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -496,19 +492,19 @@ private fun ExpandedRadioControl(
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) { if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
CardButton( CardButton(
onClick = { onAction(RadarAction.ConnectRadios) }, onClick = { onAction(RadarAction.ConnectRadios) },
text = "Connect", text = stringResource(R.string.radar_connect),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} else { } else {
CardButton( CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) }, onClick = { onAction(RadarAction.DisconnectRadios) },
text = "Disconnect", text = stringResource(R.string.radar_disconnect),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
CardButton( CardButton(
onClick = { onAction(RadarAction.ToggleTracking) }, onClick = { onAction(RadarAction.ToggleTracking) },
text = if (radioControl.isTracking) "Stop" else "Track", text = if (radioControl.isTracking) stringResource(R.string.radar_stop) else stringResource(R.string.radar_track),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
@@ -534,10 +530,10 @@ private fun DopplerFrequencyCalculator(
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var lastEditedField by rememberSaveable(transponder.uuid) { mutableStateOf(EditedField.TX) } var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) } var txFrequencyHz by remember { mutableStateOf(0L) }
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) } var rxFrequencyHz by remember { mutableStateOf(0L) }
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) } var passbandPosition by remember { 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
@@ -627,15 +623,12 @@ private fun DopplerFrequencyCalculator(
fun updateRx(newRxHz: Long) { fun updateRx(newRxHz: Long) {
when (lastEditedField) { when (lastEditedField) {
EditedField.PASSBAND -> { EditedField.PASSBAND -> {
// Passband 模式:从 RX 经完整往返路径(含多普勒补偿)反推 TX 位置 // Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val txFromRx = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
newRxHz, rawTransponder, orbitalPos, offsetHz val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
)
passbandPosition = if (txFromRx != null) { passbandPosition = if (txFromRx != null) {
((txFromRx - txLow).toFloat() / txRange).coerceIn(0f, 1f) ((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else { } else pos
((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
@@ -733,7 +726,7 @@ private fun DopplerFrequencyCalculator(
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) { if (offsetKHz.isEmpty()) {
Text( Text(
text = "Offset (kHz)", text = stringResource(R.string.radar_doppler_offset_hint),
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -904,3 +897,4 @@ 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")
@@ -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 MultiTypesDialogPreview() { private fun MultiModesDialogPreview() {
val types = listOf("Amateur", "Geostationary", "Military", "Weather") val modes = listOf("FM", "APT", "SSTV", "BPSK")
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} } MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
} }
@Composable @Composable
internal fun MultiTypesDialog( internal fun MultiModesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) { ) {
val selected = remember { mutableStateOf(types.toSet()) } val selected = remember { mutableStateOf(modes.toSet()) }
val toggle = { type: String -> val toggle = { mode: String ->
selected.value = if (type in selected.value) selected.value - type else selected.value + type selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
} }
val onAccept = { accept(selected.value.toList()) } val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(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 MultiTypesDialog(
horizontalArrangement = Arrangement.spacedBy(1.dp), horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp) verticalArrangement = Arrangement.spacedBy(1.dp)
) { ) {
itemsIndexed(allTypes) { index, item -> itemsIndexed(allModes) { index, item ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -62,10 +62,11 @@ 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
@@ -87,27 +88,27 @@ private fun SatellitesScreen(
navigateUp: () -> Unit navigateUp: () -> Unit
) { ) {
if (uiState.isDialogShown) { if (uiState.isDialogShown) {
MultiTypesDialog( MultiModesDialog(
allTypes = uiState.typesList, allModes = uiState.modesList,
types = uiState.currentTypes, modes = uiState.currentModes,
cancel = { onAction(SatellitesAction.ToggleTypesDialog) }, cancel = { onAction(SatellitesAction.ToggleModesDialog) },
accept = { onAction(SatellitesAction.SelectTypes(it)) } accept = { onAction(SatellitesAction.SelectModes(it)) }
) )
} }
if (uiState.shouldSeeWarning) { if (uiState.shouldSeeWarning) {
val dismiss = { onAction(SatellitesAction.DismissWarning) } val dismiss = { onAction(SatellitesAction.DismissWarning) }
SharedDialog( InfoDialog(
title = stringResource(R.string.sat_warning_title), title = stringResource(R.string.sat_warning_title),
onDismissRequest = dismiss, onDismiss = 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 = padding) modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) )
Spacer(modifier = Modifier.height(0.dp))
} }
} }
@@ -122,9 +123,9 @@ private fun SatellitesScreen(
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) { if (isVerticalLayout()) {
TopBar { TopBar {
TypeCard( ModeCard(
types = uiState.currentTypes, modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) }, onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
PrimaryIconCard( PrimaryIconCard(
@@ -156,9 +157,9 @@ private fun SatellitesScreen(
resId = R.drawable.ic_done, resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd } modifier = Modifier.semantics { contentDescription = primCardCd }
) )
TypeCard( ModeCard(
types = uiState.currentTypes, modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) }, onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
SearchBar( SearchBar(
@@ -240,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
} }
@Composable @Composable
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) { private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ") val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
ElevatedCard(modifier = modifier) { ElevatedCard(modifier = modifier) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -251,12 +252,12 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
.clickable { onClick() }) { .clickable { onClick() }) {
Spacer(Modifier) Spacer(Modifier)
Icon( Icon(
painter = painterResource(id = R.drawable.ic_tags), painter = painterResource(id = R.drawable.ic_radios),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = stringResource(R.string.sat_type_hint, typesText), text = stringResource(R.string.sat_type_hint, modesText),
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier modifier = Modifier
@@ -290,17 +291,14 @@ 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(text = "$passSatId - ", color = MaterialTheme.colorScheme.primary)
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.primary color = MaterialTheme.colorScheme.onSurface
) )
Checkbox( Checkbox(
checked = item.isSelected, checked = item.isSelected,
@@ -325,9 +323,54 @@ private fun SatellitesPreview() {
@Composable @Composable
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) { private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
val selectedItems = items.filter { it.isSelected }
val availableItems = items.filterNot { it.isSelected }
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) { LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry -> stickyHeader(key = "sat_summary_header") {
Satellite(entry, onSelected, Modifier.animateItem()) SummaryHeader(
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
)
}
if (selectedItems.isNotEmpty()) {
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
if (availableItems.isNotEmpty()) {
items(items = availableItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
} }
} }
} }
@Composable
private fun SummaryHeader(selectedText: String, availableText: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = selectedText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = availableText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -24,8 +24,8 @@ data class SatellitesState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val shouldSeeWarning: Boolean = false, val shouldSeeWarning: Boolean = false,
val itemsList: List<SatItem> = emptyList(), val itemsList: List<SatItem> = emptyList(),
val currentTypes: List<String> = emptyList(), val currentModes: List<String> = emptyList(),
val typesList: List<String> = emptyList() val modesList: List<String> = emptyList()
) )
sealed interface SatellitesAction { sealed interface SatellitesAction {
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
data class SearchFor(val query: String) : SatellitesAction data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction data class SelectModes(val modes: List<String>) : SatellitesAction
data object ToggleTypesDialog : SatellitesAction data object ToggleModesDialog : SatellitesAction
data object UnselectAll : SatellitesAction data object UnselectAll : SatellitesAction
} }
@@ -35,13 +35,13 @@ class SatellitesViewModel(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val defaultTypes = selectionRepo.getCurrentTypes() private val defaultModes = selectionRepo.getCurrentModes()
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
SatellitesState( SatellitesState(
isLoading = true, isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning, shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
currentTypes = defaultTypes, currentModes = defaultModes,
typesList = selectionRepo.getTypesList() modesList = selectionRepo.getModesList()
) )
) )
val uiState: StateFlow<SatellitesState> = _uiState val uiState: StateFlow<SatellitesState> = _uiState
@@ -49,7 +49,7 @@ class SatellitesViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
selectionRepo.setQuery(String()) selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes) selectionRepo.setModes(defaultModes)
selectionRepo.getEntriesFlow().collectLatest { items -> selectionRepo.getEntriesFlow().collectLatest { items ->
_uiState.update { it.copy(isLoading = false, itemsList = items) } _uiState.update { it.copy(isLoading = false, itemsList = items) }
} }
@@ -59,6 +59,11 @@ class SatellitesViewModel(
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) } _uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
} }
} }
viewModelScope.launch {
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(currentModes = modes) }
}
}
} }
fun onAction(action: SatellitesAction) { fun onAction(action: SatellitesAction) {
@@ -68,8 +73,8 @@ class SatellitesViewModel(
is SatellitesAction.SearchFor -> searchFor(action.query) is SatellitesAction.SearchFor -> searchFor(action.query)
SatellitesAction.SelectAll -> selectAll(true) SatellitesAction.SelectAll -> selectAll(true)
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked) is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
is SatellitesAction.SelectTypes -> selectTypes(action.types) is SatellitesAction.SelectModes -> selectModes(action.modes)
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog() SatellitesAction.ToggleModesDialog -> toggleModesDialog()
SatellitesAction.UnselectAll -> selectAll(false) SatellitesAction.UnselectAll -> selectAll(false)
} }
} }
@@ -86,12 +91,12 @@ class SatellitesViewModel(
selectionRepo.setSelection(listOf(id), isTicked.not()) selectionRepo.setSelection(listOf(id), isTicked.not())
} }
private fun selectTypes(types: List<String>) = viewModelScope.launch { private fun selectModes(modes: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types) selectionRepo.setModes(modes)
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) } _uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
} }
private fun toggleTypesDialog() { private fun toggleModesDialog() {
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) } _uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
} }
@@ -17,56 +17,66 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager import androidx.compose.animation.core.FastOutSlowInEasing
import android.content.Context import androidx.compose.animation.core.tween
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Checkbox
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.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.snapshots.SnapshotStateList
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.graphics.Color
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.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.DragReorderState
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.SharedDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import androidx.compose.animation.core.spring import com.rtbishop.look4sat.core.presentation.dragHandle
import androidx.compose.foundation.lazy.LazyColumn import com.rtbishop.look4sat.core.presentation.dragLift
import androidx.compose.foundation.lazy.LazyListScope import com.rtbishop.look4sat.core.presentation.rememberDragReorderState
import androidx.compose.foundation.lazy.itemsIndexed import com.rtbishop.look4sat.core.presentation.rememberDragRowState
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
@@ -80,19 +90,28 @@ 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() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) { ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
OutlinedTextField( Row(
value = latValue.value, horizontalArrangement = Arrangement.spacedBy(6.dp),
onValueChange = { latValue.value = it }, modifier = Modifier
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }, .fillMaxWidth()
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), .padding(horizontal = LocalSpacing.current.large)
) ) {
OutlinedTextField( OutlinedTextField(
value = lonValue.value, value = latValue.value,
onValueChange = { lonValue.value = it }, onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }, label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), singleLine = true,
) modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(0.dp)) Spacer(modifier = Modifier.height(0.dp))
} }
} }
@@ -115,12 +134,18 @@ private fun LocatorDialogPreview() {
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) { fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) } val locator = rememberSaveable { mutableStateOf(qthLocator) }
val onAccept = { save(locator.value).also { dismiss() } } val onAccept = { save(locator.value).also { dismiss() } }
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) { ConfirmDialog(
title = stringResource(R.string.prefs_locator_title),
onCancel = dismiss,
onAccept = onAccept
) {
OutlinedTextField( OutlinedTextField(
value = locator.value, value = locator.value,
onValueChange = { locator.value = it }, onValueChange = { locator.value = it },
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) }, label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large),
) )
Spacer(modifier = Modifier.height(0.dp)) Spacer(modifier = Modifier.height(0.dp))
} }
@@ -138,50 +163,94 @@ private fun TransceiversDialogPreview() {
transceiversUrls = listOf( transceiversUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active" "db.satnogs.org/api/transmitters/?format=json&status=active"
), ),
satelliteEnabled = listOf(true, false),
transceiversEnabled = listOf(true),
statusCodes = mapOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
"amsat.org/tle/current/nasabare.txt" to 404
),
onImportTle = {}, onImportTle = {},
onImportTransceivers = {}, onImportTransceivers = {},
onDismiss = {}, onDismiss = {},
onSave = { _, _ -> } onSave = { _, _, _, _ -> }
) )
} }
} }
/**
* Single source of truth for one data-source row: url, enabled flag and stable id all live
* together so a reorder, a toggle or an edit is a single list mutation instead of several parallel
* lists/maps having to stay in sync (which used to fan out into extra recompositions).
*/
private data class SourceEntry(val id: Long, val url: String, val enabled: Boolean = true)
@Composable @Composable
fun DataSourcesDialog( fun DataSourcesDialog(
satelliteUrls: List<String>, satelliteUrls: List<String>,
transceiversUrls: List<String>, transceiversUrls: List<String>,
satelliteEnabled: List<Boolean>,
transceiversEnabled: List<Boolean>,
statusCodes: Map<String, Int>,
onImportTle: () -> Unit, onImportTle: () -> Unit,
onImportTransceivers: () -> Unit, onImportTransceivers: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (List<String>, List<String>) -> Unit onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
// Stable Long IDs avoid key collisions when several entries are empty or duplicated. // Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) } val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satUrls = remember { val satEntries = remember {
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList() satelliteUrls.mapIndexed { i, url ->
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
}.toMutableStateList()
} }
val txUrls = remember { val txEntries = remember {
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList() transceiversUrls.mapIndexed { i, url ->
SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true })
}.toMutableStateList()
}
val listState = rememberLazyListState()
val dragState = rememberDragReorderState(listState)
val onRestoreSatDefaults = {
satEntries.clear()
satEntries.addAll(Sources.satelliteDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
Unit
}
val onRestoreTxDefaults = {
txEntries.clear()
txEntries.addAll(Sources.transceiversDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
Unit
} }
val onAccept = { val onAccept = {
onSave( val satFiltered = satEntries.filter { it.url.isNotBlank() }
satUrls.map { it.second.trim() }.filter { it.isNotBlank() }, val txFiltered = txEntries.filter { it.url.isNotBlank() }
txUrls.map { it.second.trim() }.filter { it.isNotBlank() } try {
) onSave(
onDismiss() satFiltered.map { it.url },
txFiltered.map { it.url },
satFiltered.map { it.enabled },
txFiltered.map { it.enabled }
)
} finally {
onDismiss()
}
} }
SharedDialog( val satTitle = stringResource(R.string.prefs_data_sources_sat_title)
val satHint = stringResource(R.string.prefs_data_sources_sat_hint)
val transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title)
val transceiversHint = stringResource(R.string.prefs_data_sources_transceivers_hint)
ConfirmDialog(
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,
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.fillMaxHeight(0.84f) .fillMaxHeight(0.84f)
.padding(horizontal = padding), .padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 6.dp) contentPadding = PaddingValues(vertical = 0.dp)
) { ) {
item { item {
Row( Row(
@@ -202,21 +271,31 @@ fun DataSourcesDialog(
} }
sourceSection( sourceSection(
sectionKey = "sat", sectionKey = "sat",
labelResId = R.string.prefs_data_sources_satellites_label, label = satTitle,
urls = satUrls, hint = satHint,
onAdd = { satUrls.add(nextId.longValue++ to "") }, entries = satEntries,
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) }, dragState = dragState,
onRemove = { i -> satUrls.removeAt(i) }, statusCodes = statusCodes,
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v } onAdd = { satEntries.add(SourceEntry(nextId.longValue++, "")) },
onRestore = onRestoreSatDefaults,
onMove = { from, to -> satEntries.add(to, satEntries.removeAt(from)) },
onRemove = { i -> satEntries.removeAt(i) },
onToggle = { i -> satEntries[i] = satEntries[i].copy(enabled = !satEntries[i].enabled) },
onUrlChange = { i, v -> satEntries[i] = satEntries[i].copy(url = v) }
) )
sourceSection( sourceSection(
sectionKey = "tx", sectionKey = "tx",
labelResId = R.string.prefs_data_sources_transceivers_label, label = transceiversTitle,
urls = txUrls, hint = transceiversHint,
onAdd = { txUrls.add(nextId.longValue++ to "") }, entries = txEntries,
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) }, dragState = dragState,
onRemove = { i -> txUrls.removeAt(i) }, statusCodes = statusCodes,
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v } onAdd = { txEntries.add(SourceEntry(nextId.longValue++, "")) },
onRestore = onRestoreTxDefaults,
onMove = { from, to -> txEntries.add(to, txEntries.removeAt(from)) },
onRemove = { i -> txEntries.removeAt(i) },
onToggle = { i -> txEntries[i] = txEntries[i].copy(enabled = !txEntries[i].enabled) },
onUrlChange = { i, v -> txEntries[i] = txEntries[i].copy(url = v) }
) )
} }
} }
@@ -224,55 +303,128 @@ fun DataSourcesDialog(
private fun LazyListScope.sourceSection( private fun LazyListScope.sourceSection(
sectionKey: String, sectionKey: String,
labelResId: Int, label: String,
urls: List<Pair<Long, String>>, hint: String,
entries: SnapshotStateList<SourceEntry>,
dragState: DragReorderState,
statusCodes: Map<String, Int>,
onAdd: () -> Unit, onAdd: () -> Unit,
onMoveUp: (Int) -> Unit, onRestore: () -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit, onRemove: (Int) -> Unit,
onToggle: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit onUrlChange: (Int, String) -> Unit
) { ) {
item { item {
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 = stringResource(labelResId), text = label,
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) IconCard(
action = onRestore,
resId = R.drawable.ic_restore,
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
IconCard(
action = onAdd,
resId = R.drawable.ic_add,
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
} }
} Text(
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) -> text = hint,
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
OutlinedTextField(
value = url,
onValueChange = { onUrlChange(index, it) },
label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
leadingIcon = {
IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) {
Icon(
painter = painterResource(R.drawable.ic_arrow),
contentDescription = null,
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
modifier = Modifier.rotate(270f)
)
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring()) .padding(top = 2.dp, bottom = 8.dp)
) )
} }
itemsIndexed(entries, key = { _, entry -> "$sectionKey-${entry.id}" }) { index, entry ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
val rowState = rememberDragRowState(
dragState = dragState,
items = entries,
item = entry,
key = { "$sectionKey-${it.id}" },
onMove = onMove
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.dragLift(isLifted = rowState.isLifted, translationY = rowState.translationY)
.animateItem(
fadeInSpec = tween(durationMillis = 200),
// The dragged row repositions instantly, while its neighbors smoothly
// slide out of the way (the "squeeze" effect).
placementSpec = if (rowState.isDragging) {
tween(durationMillis = 0)
} else {
tween(durationMillis = 250, easing = FastOutSlowInEasing)
},
fadeOutSpec = tween(durationMillis = 200)
)
) {
Icon(
painter = painterResource(R.drawable.ic_drag_handle),
contentDescription = null,
tint = enabledTint,
modifier = Modifier
.padding(top = textFieldLabelOffset)
.dragHandle(rowState)
)
Spacer(modifier = Modifier.width(12.dp))
OutlinedTextField(
value = entry.url,
onValueChange = { onUrlChange(index, it) },
label = {
Row {
Text("Source URL")
statusCodes[entry.url]?.let { code ->
Text(" - ${statusLabel(code)}", color = statusColor(code), fontSize = 12.sp)
}
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
enabled = entry.enabled,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = entry.enabled,
onCheckedChange = { onToggle(index) },
modifier = Modifier.padding(top = textFieldLabelOffset)
)
}
}
}
/**
* Compensates for the visual weight of the [OutlinedTextField]'s floating label: the label
* pushes the perceived center of the field's content down a bit, so the drag handle and
* checkbox are nudged down by the same amount to stay visually centered on the input line.
*/
private val textFieldLabelOffset = 8.dp
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
@Composable
private fun statusColor(code: Int): Color = when (code) {
NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
in 200..299 -> Color(0xFF66BB6A)
else -> MaterialTheme.colorScheme.error
} }
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -336,7 +488,7 @@ fun NetworkOutputDialog(
) )
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_net_title), title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -377,7 +529,10 @@ fun NetworkOutputDialog(
onClick = { frequencyOffsetHz.value = "0" }, onClick = { frequencyOffsetHz.value = "0" },
enabled = frequencyState.value && frequencyOffsetHz.value != "0" enabled = frequencyState.value && frequencyOffsetHz.value != "0"
) { ) {
Icon(painter = painterResource(R.drawable.ic_close), contentDescription = null) Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = null
)
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -437,11 +592,15 @@ fun BluetoothOutputDialog(
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) } val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) } val rotatorAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) } val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) } val frequencyState =
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) } rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) } val frequencyAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = { val onAccept = {
onSave( onSave(
rotatorState.value, rotatorAddress.value, rotatorFormat.value, rotatorState.value, rotatorAddress.value, rotatorFormat.value,
@@ -449,7 +608,7 @@ fun BluetoothOutputDialog(
) )
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_bt_title), title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -530,23 +689,22 @@ 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 context = androidx.compose.ui.platform.LocalContext.current val padding = LocalSpacing.current.large
val padding = LocalSpacing.current.large val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) } val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) } val 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
@@ -556,157 +714,157 @@ fun RadioControlDialog(
if (!isIcom && splitMode.value) splitMode.value = false if (!isIcom && splitMode.value) splitMode.value = false
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
else RadioControlSettings.BAUD_RATES_YAESU else RadioControlSettings.BAUD_RATES_YAESU
// If current baud rate is not in the new list, default to the first available // If current baud rate is not in the new list, default to the first available
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first() if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
val pairedDevices: List<Pair<String, String>> = remember {
buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach {
add(Pair(it.name ?: "Unknown", it.address ?: ""))
}
} catch (_: SecurityException) { }
}
}
val onAccept = { val onAccept = {
onSave( onSave(
RadioControlSettings( RadioControlSettings(
enabled = enabled.value, enabled = enabled.value,
radioModel = radioModel.value, radioModel = radioModel.value,
txRadioAddress = txAddress.value, txRadioAddress = txAddress.value,
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value, rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
txRadioName = txName.value, txRadioName = txName.value,
rxRadioName = if (isSingleRadio) "" else rxName.value, rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue, baudRate = baudRate.intValue,
splitMode = splitMode.value splitMode = splitMode.value
) )
) )
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
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 = androidx.compose.material3.MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth()
) {
RadioControlSettings.SUPPORTED_RADIOS.forEach { model -> RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
FilterChip( FilterChip(
selected = radioModel.value == model, selected = radioModel.value == model,
onClick = { radioModel.value = model }, onClick = { radioModel.value = model },
label = { Text(model, fontSize = 12.sp) }, label = {
enabled = enabled.value Text(
text = compactRadioModelLabel(model),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
) )
} }
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// IC-705 split-mode toggle (only shown for IC-705) val isSplitModeAvailable = enabled.value && isIcom
if (isIcom) { val splitModeLabelColor = if (isSplitModeAvailable) {
Row( MaterialTheme.colorScheme.onSurface
horizontalArrangement = Arrangement.SpaceBetween, } else {
verticalAlignment = Alignment.CenterVertically, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
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(
// TX Radio (always shown; in split mode this is the single IC-705) horizontalArrangement = Arrangement.SpaceBetween,
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)" verticalAlignment = Alignment.CenterVertically,
Text(txLabel, fontWeight = FontWeight.Medium)
if (txAddress.value.isNotBlank()) {
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
}
CardButton(
onClick = { selectingFor.value = "tx" },
text = if (isSingleRadio) "Select Device" else "Select TX Device",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) ) {
Text(
text = stringResource(R.string.settings_split_mode),
fontWeight = FontWeight.Medium,
color = splitModeLabelColor
)
Switch(
checked = splitMode.value,
onCheckedChange = { splitMode.value = it },
enabled = isSplitModeAvailable
)
}
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// RX Radio (hidden in split mode — the same radio handles both) Text("Radio devices", fontWeight = FontWeight.Medium)
if (!isSingleRadio) { Row(
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium) horizontalArrangement = Arrangement.spacedBy(6.dp),
if (rxAddress.value.isNotBlank()) { modifier = Modifier.fillMaxWidth()
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp) ) {
}
CardButton( CardButton(
onClick = { selectingFor.value = "rx" }, onClick = { selectingFor.value = "tx" },
text = "Select RX Device", text = if (isSingleRadio) "Select Radio" else "Select TX",
modifier = Modifier.fillMaxWidth() modifier = Modifier.weight(1f)
)
CardButton(
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
text = if (isSingleRadio) "RX = TX" else "Select RX",
modifier = Modifier.weight(1f)
) )
Spacer(modifier = Modifier.height(6.dp))
} }
if (txAddress.value.isNotBlank()) {
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
}
if (isSingleRadio && txAddress.value.isNotBlank()) {
Text("RX: same as TX", fontSize = 13.sp)
} else if (rxAddress.value.isNotBlank()) {
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
}
Spacer(modifier = Modifier.height(6.dp))
// Paired device picker (inline, shown while selecting)
if (selectingFor.value.isNotBlank()) { if (selectingFor.value.isNotBlank()) {
Text( Text(
text = "Paired Bluetooth Devices:", text = stringResource(R.string.settings_paired_devices),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
if (pairedDevices.isEmpty()) { if (pairedBluetoothDevices.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 {
pairedDevices.forEach { (name, address) -> pairedBluetoothDevices.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))
@@ -718,15 +876,26 @@ 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(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FlowRow(
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 = { Text(rate.toString(), fontSize = 12.sp) }, label = {
enabled = enabled.value Text(
text = baudLabel(rate),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
) )
} }
} }
@@ -734,3 +903,20 @@ fun RadioControlDialog(
} }
} }
} }
private fun compactRadioModelLabel(model: String): String = when (model) {
RadioControlSettings.MODEL_YAESU_FT817 -> "FT-817/818"
RadioControlSettings.MODEL_YAESU_FT857 -> "FT-857/897"
RadioControlSettings.MODEL_ICOM_IC705 -> "IC-705"
else -> model
}
private fun baudLabel(rate: Int): String = when (rate) {
4800 -> "4k"
9600 -> "9k"
19200 -> "19k"
38400 -> "38k"
57600 -> "57k"
115200 -> "115k"
else -> rate.toString()
}
@@ -25,10 +25,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
@@ -38,6 +40,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -72,6 +75,7 @@ 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
@@ -90,22 +94,10 @@ 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
@@ -128,11 +120,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
DataSourcesDialog( DataSourcesDialog(
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls, satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls, transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
statusCodes = uiState.dataSourcesStatus,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false }, onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false }, onDismiss = { dialogs.dataSources = false },
onSave = { satUrls, txUrls -> onSave = { satUrls, txUrls, satEnabled, txEnabled ->
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls) val newSettings = DataSourcesSettings(
satelliteUrls = satUrls,
transceiversUrls = txUrls,
satelliteEnabled = satEnabled,
transceiversEnabled = txEnabled
)
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings)) if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb) onAction(SettingsAction.UpdateFromWeb)
} }
@@ -177,14 +177,18 @@ 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
val appUrl = stringResource(R.string.prefs_app_url) // val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url) val donateUrl = stringResource(R.string.prefs_donate_url)
val fdroidTitle = stringResource(R.string.prefs_fdroid_title) val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
val fdroidUrl = stringResource(R.string.prefs_fdroid_url) val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
@@ -201,7 +205,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (isVerticalLayout) { if (isVerticalLayout) {
TopBar { TopBar {
TopCard( TopCard(
onClick = { safeOpenUri(appUrl) }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -229,7 +233,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
TopBar { TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard( TopCard(
onClick = { safeOpenUri(appUrl) }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -285,8 +289,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true } onRadioControlClick = { dialogs.radioControl = true }
) )
} }
item { OtherCard(uiState.otherSettings, onAction) } item(span = { GridItemSpan(maxLineSpan) }) {
item { CardCredits() } if (isVerticalLayout) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OtherCard(uiState.otherSettings, onAction)
CardCredits()
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
) {
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
} }
} }
} }
@@ -324,9 +342,9 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp)) Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°") Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude))
Text(text = "Lon: ${settings.stationPos.longitude}°") Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude))
Text(text = "Qth: ${settings.stationPos.qthLocator}") Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator))
} }
Spacer(modifier = Modifier.height(1.dp)) Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) { Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -465,40 +483,83 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false, shouldSeeWarning = false,
shouldSeeWhatsNew = false shouldSeeWhatsNew = false
) )
OtherCard(settings = values) {} OtherCard(settings = values, onAction = {})
} }
@Composable @Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) { private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
ElevatedCard( ElevatedCard(modifier = modifier.fillMaxWidth()) {
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text( Text(
text = stringResource(id = R.string.prefs_other_title), text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) { Spacer(modifier = Modifier.height(4.dp))
onAction(SettingsAction.ToggleUtc(it)) // Display preferences: UTC clock + night filter
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
}
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
} }
Spacer(modifier = Modifier.height(4.dp))
// Radar behavior: sweep animation + sensor control
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Data management (full width)
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) { SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it)) onAction(SettingsAction.ToggleUpdate(it))
} }
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) { Spacer(modifier = Modifier.height(4.dp))
onAction(SettingsAction.ToggleSweep(it)) // Compass calibration sliders at the bottom
} CompassOffsetRow(
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) { labelResId = R.string.prefs_other_compass_offset,
onAction(SettingsAction.ToggleSensor(it)) value = settings.radarCompassOffset,
} range = -180f..180f
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { ) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
onAction(SettingsAction.ToggleNightMode(it)) Spacer(modifier = Modifier.height(4.dp))
} CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset_elev,
value = settings.radarCompassOffsetElev,
range = -90f..90f
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
Spacer(modifier = Modifier.height(4.dp))
} }
} }
} }
@Composable
private fun CompassOffsetRow(
labelResId: Int,
value: Float,
range: ClosedFloatingPointRange<Float>,
onValueChange: (Float) -> Unit
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = labelResId))
Text(text = "${value.toInt()}°")
}
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = value,
onValueChange = onValueChange,
valueRange = range
)
}
@Composable @Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row( Row(
@@ -511,6 +572,21 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
} }
} }
@Composable
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(id = labelResId),
modifier = Modifier.weight(1f)
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable @Composable
private fun formatUpdateTime(updateTime: Long): String { private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time) val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -545,7 +621,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard( ElevatedCard(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(268.dp)
) { ) {
Column( Column(
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
@@ -579,7 +654,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_satellites), painter = painterResource(id = R.drawable.ic_sputnik),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -630,6 +705,7 @@ 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
@@ -637,12 +713,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) listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
}, },
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] network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
} }
} }
) )
@@ -659,17 +735,14 @@ 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(
@@ -706,15 +779,6 @@ 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 = {
@@ -731,13 +795,6 @@ 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()
}
} }
) )
} }
@@ -41,7 +41,9 @@ data class SettingsState(
val otherSettings: OtherSettings, val otherSettings: OtherSettings,
val rcSettings: RCSettings, val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings, val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
) )
sealed interface SettingsAction { sealed interface SettingsAction {
@@ -64,6 +66,8 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction data class ToggleNightMode(val value: Boolean) : SettingsAction
data class SetRadarCompassOffset(val value: Float) : SettingsAction
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
// Remote control // Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel( class SettingsViewModel(
private val container: IMainContainer,
private val databaseRepo: IDatabaseRepo, private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast private val showToast: IShowToast
@@ -47,7 +48,9 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value, otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value, rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value, radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
pairedBluetoothDevices = container.providePairedBluetoothDevices()
) )
) )
@@ -90,6 +93,11 @@ class SettingsViewModel(
_uiState.update { it.copy(dataSourcesSettings = settings) } _uiState.update { it.copy(dataSourcesSettings = settings) }
} }
} }
viewModelScope.launch {
settingsRepo.dataSourcesStatus.collect { status ->
_uiState.update { it.copy(dataSourcesStatus = status) }
}
}
viewModelScope.launch { viewModelScope.launch {
settingsRepo.radioControlSettings.collect { settings -> settingsRepo.radioControlSettings.collect { settings ->
_uiState.update { it.copy(radioControlSettings = settings) } _uiState.update { it.copy(radioControlSettings = settings) }
@@ -121,6 +129,8 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) } is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) } is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) } is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
// Remote control & data sources // Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
@@ -196,7 +206,6 @@ class SettingsViewModel(
} }
} }
// endregion // endregion
companion object { companion object {
@@ -204,6 +213,7 @@ 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()
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more