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
96 changed files with 6787 additions and 1231 deletions

No files matched your search

+4
View File
@@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "never"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '21'
+1
View File
@@ -64,3 +64,4 @@ fastlane/readme.md
/app/release/output-metadata.json
/app/release/
/.kotlin/sessions/
.hermes/
+48 -49
View File
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
## Project Overview
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
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
data download.
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
no tracking, no network required after initial data download.
## Architecture
## Architecture & Design
**MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class → exposed via `StateFlow` from ViewModel
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
@@ -34,9 +33,11 @@ data download.
| `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences |
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
**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
# Debug build
@@ -50,54 +51,55 @@ data download.
```
- **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
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
- **Room** (KSP code generation) for local satellite/TLE storage
- **OkHttp** 5.x for TLE downloads
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
- **Room** (KSP code generation) for local satellite/orbital storage
- **OkHttp** 5.x for data downloads
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and data parsing
- **Kotlin Serialization** for navigation args and parsing
- **Coroutines** + `StateFlow` for async/reactive patterns
## Conventions
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
## Data Formats & Migration
**TLE vs. OMM/CSV format:**
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) 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
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.
## Engineering Heuristics (Lazy = Efficient)
**Current implementation:**
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
- TLE data is downloaded from configured sources and stored in Room database
- When downloading satellite data, the app automatically detects format and parses accordingly
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
- Treat "lazy" as efficient, not careless: the best code is the code never written.
- First understand the task and trace the real flow end-to-end, then climb this ladder:
1. Does this need to be built now? (YAGNI)
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
3. Does Kotlin/Java stdlib already solve it?
4. Does the Android/platform API already solve it?
5. Does an already-installed dependency solve it?
6. Can this be simpler (including one-liner simple)?
7. Only then: write the minimum code that works.
- Prefer deletion to addition, boring over clever, and the fewest touched files.
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
- When two options are similar in size, choose the edge-case-correct one.
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
**Migration path:**
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
to handle this transition without code changes — existing users can continue using TLE files while new sources
transition to OMM/CSV automatically.
## Bug-Fix Policy
## Code Style
- Prefer **short, focused functions** — single responsibility, easy to read.
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
- Strict code style — no dead code, no unused imports, consistent formatting.
- Fix root cause, not just the reported symptom.
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
- The smallest correct diff wins only after behavior is understood.
## Roadmap
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
## Gotchas
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
## Copilot Working Mode: Code-Only
+4 -4
View File
@@ -36,10 +36,10 @@ It is now and always will be completely ad-free and open-source.
## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<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=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { MainScreen() }
MainTheme(isDarkTheme = true) { NavRoot() }
}
}
@@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -70,6 +68,8 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -82,37 +82,42 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver()
LaunchedEffect(deeplink) {
deeplink?.let {
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
transitionSpec = { slideInTransition },
popTransitionSpec = { slideOutTransition },
predictivePopTransitionSpec = { slideOutTransition },
transitionSpec = { pushTransition },
popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
entry<RadarDestination> {
Scaffold { innerPadding ->
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
}
}
}
@@ -120,18 +125,20 @@ fun NavRoot(deeplink: String? = null) {
}
@Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation,
@@ -144,13 +151,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Status -> screen is Screen.Status
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
item(
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
@@ -179,9 +191,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
@@ -191,69 +201,71 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
navigateToRadar()
}
}
entry<Screen.Radar> {
RadarDestination(navigateUp = navigateBack)
}
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
entry<Screen.Status> { SatStatusDestination() }
entry<Screen.Map> { MapDestination() }
entry<Screen.Settings> { SettingsDestination() }
}
)
// Radio tracking status banner
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar)
}
TrackingBanner(
state = trackingState,
onClick = {
val pass = trackingState.currentPass
if (pass != null) {
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
)
}
}
@@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:radar"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
+40
View File
@@ -5,6 +5,46 @@ plugins {
alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) 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) {
+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.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
@@ -39,13 +40,30 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
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)
suspend fun insertEntries(entries: List<SatEntry>)
@Query("DELETE FROM entries")
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>
@Query("SELECT COUNT(*) FROM radios")
@@ -60,4 +78,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios")
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.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.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 fun look4SatDao(): Look4SatDao
}
//val MIGRATION_1_2 = object : Migration(1, 2) {
// override fun migrate(database: SupportSQLiteDatabase) {
// 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))")
// 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")
// database.execSQL("DROP TABLE entries")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
// }
//}
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
* transceivers that were imported from a file, so that remote updates leave them alone. */
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
}
}
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double,
val meanan: Double,
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 uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -17,8 +17,10 @@
*/
package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -31,18 +33,30 @@ class NetworkReporter(
private val rotatorServer: String,
private val rotatorPort: Int,
private val frequencyServer: String,
private val frequencyPort: Int
private val frequencyPort: Int,
private val frequencyOffsetHz: Long = 0L
) : IReporter {
private val writeMutex = Mutex()
private val connectionMutex = Mutex()
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false
private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false
private var frequencyConnecting = false
init {
// Keep only the latest frequency command to avoid stale backlog and effective lag.
reporterScope.launch {
for (command in frequencyCommands) {
ensureFrequencyConnected()
if (!frequencyConnected) continue
write(frequencySocket, command) { resetFrequencyConnection() }
}
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch {
@@ -58,46 +72,43 @@ class NetworkReporter(
}
override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch {
ensureFrequencyConnected()
if (!frequencyConnected) return@launch
val command = format
.replace($$"$FREQ", frequency.toString())
.unescapeControlChars()
write(frequencySocket, command) { frequencyConnected = false }
}
val clampedOffset = frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
val command = format
.replace($$"$FREQ", correctedFreq.toString())
.unescapeControlChars()
frequencyCommands.trySend(command)
}
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
reporterScope.launch {
private suspend fun ensureRotatorConnected() {
connectionMutex.withLock {
if (rotatorConnected || rotatorServer.isBlank()) return
try {
rotatorConnecting = true
resetRotatorConnection()
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false
} finally {
rotatorConnecting = false
resetRotatorConnection()
}
}
}
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
reporterScope.launch {
private suspend fun ensureFrequencyConnected() {
connectionMutex.withLock {
if (frequencyConnected || frequencyServer.isBlank()) return
try {
frequencyConnecting = true
resetFrequencyConnection()
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false
} finally {
frequencyConnecting = false
resetFrequencyConnection()
}
}
}
@@ -106,7 +117,9 @@ class NetworkReporter(
try {
writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer)
while (buffer.hasRemaining()) {
socket?.write(buffer)
}
}
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
@@ -114,6 +127,32 @@ class NetworkReporter(
}
}
private fun resetRotatorConnection() {
rotatorConnected = false
closeQuietly(rotatorSocket)
rotatorSocket = null
}
private fun resetFrequencyConnection() {
frequencyConnected = false
closeQuietly(frequencySocket)
frequencySocket = null
}
private fun closeQuietly(socket: SocketChannel?) {
try {
socket?.close()
} catch (_: Exception) {}
}
private fun Long.safeAdd(delta: Long): Long {
return when {
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
else -> this + delta
}
}
private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
}
@@ -22,13 +22,17 @@ import android.content.Context
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager
import androidx.core.content.pm.PackageInfoCompat
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.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -66,12 +70,14 @@ import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource = provideRemoteSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -103,7 +109,8 @@ class MainContainer(private val context: Context) : IMainContainer {
rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0
rc.frequencyPort.toIntOrNull() ?: 0,
rc.frequencyOffsetHz
)
}
@@ -135,21 +142,34 @@ class MainContainer(private val context: Context) : IMainContainer {
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 {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.fallbackToDestructiveMigration(false).build()
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
return LocalSource(database.look4SatDao())
}
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 {
@@ -164,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
return SettingsRepo(manager, appPreferences, appVersionName)
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val appVersionName = packageInfo.versionName ?: "4.0.4"
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
}
}
@@ -0,0 +1,209 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("name", submission.name)
put("report", submission.report)
put("callsign", submission.callsign)
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
}
val response = remoteSource.submitAmSatReport(payload.toString())
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
val (code, body) = response
val errorMessage = parseSubmitError(body)
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
} else {
AmSatReportSubmitResult(
success = false,
message = errorMessage.ifBlank { "HTTP $code" }
)
}
}
private fun parseSubmitError(json: String): String {
return try {
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
} catch (_: Exception) {
""
}
}
private fun parseSubmitReportId(json: String): String? {
return try {
val obj = JSONObject(json)
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
?: obj.optString("id").takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (_: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (_: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (_: Exception) {
0L
}
}
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
formatDayLabel(utc)
}
return names.map { name ->
val slots = (0 until 36).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun formatDayLabel(calendar: Calendar): String {
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
} else {
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
}
companion object {
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -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.source.ILocalSource
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
@@ -40,14 +39,13 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo
) : IDatabaseRepo {
private val customSourceType = "Other"
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
insertFresherEntries(entries)
// report what the file contained: a valid file holding only stale data is not an error
importedCount = entries.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -58,7 +56,7 @@ class DatabaseRepo(
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers)
localSource.insertRadios(transceivers, isCustom = true)
importedCount = transceivers.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -66,32 +64,40 @@ class DatabaseRepo(
}
override suspend fun updateFromRemote() = withContext(dispatcher) {
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently and associate with types
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
}
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
val settings = settingsRepo.dataSourcesSettings.value
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
// launch all network requests concurrently, keeping the raw url as key for status reporting
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
// report the HTTP status code of every source (200, 404, ...)
settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code }
)
// parse fetched data concurrently, merging satellites by freshness instead of source order
val parsedPerSource = tleResults.map { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
}
val importedRadios = radioResults.flatMap { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis())
insertFresherEntries(mergeEntries(parsedPerSource))
// transceivers are a full snapshot: sources publish active entries only, so a retired
// 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) {
@@ -127,6 +133,62 @@ class DatabaseRepo(
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) {
settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -34,9 +34,11 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -63,19 +65,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
}
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -158,7 +164,7 @@ class SatelliteRepo(
}
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
delay(1000.milliseconds) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
@@ -176,7 +182,7 @@ class SatelliteRepo(
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
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.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
val ids = localSource.getIdsWithModes(list)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -56,14 +56,18 @@ class SelectionRepo(
}
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 {
removeAt(0)
}
override fun getModesList() = Sources.satelliteModes
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -73,9 +77,8 @@ class SelectionRepo(
return@withContext itemsWithQuery
}
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
override suspend fun setModes(modes: List<String>) {
settingsRepo.setSelectedSatModes(modes)
}
override suspend fun setQuery(query: String) {
@@ -111,12 +114,29 @@ class SelectionRepo(
/**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* 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> {
if (query.isBlank()) return items
val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase()
return items.filter { it.name.lowercase().contains(lowerQuery) }
val tokens = query.split(' ')
.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,19 +29,24 @@ 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.Constants
import com.rtbishop.look4sat.core.domain.predict.GeoPos
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.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONObject
import java.util.Locale
class SettingsRepo(
private val locationManager: LocationManager,
private val preferences: SharedPreferences,
override val appVersionName: String
override val appVersionName: String,
override val appVersionCode: Long,
) : ISettingsRepo, LocationListenerCompat {
private val keyBluetoothRotatorAddress = "bluetoothAddress"
@@ -67,9 +72,9 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
@@ -83,21 +88,38 @@ class SettingsRepo(
private val keyStationTimestamp = "stationTimestamp"
private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keySatellitesUrls = "satellitesUrls"
private val keyTransceiversUrls = "transceiversUrls"
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
private val keySettingsVersion = "settingsVersion"
private val separatorComma = ","
private val separatorUrl = "\n"
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
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
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>) {
val selectionString = ids.joinToString(separatorComma)
@@ -105,10 +127,10 @@ class SettingsRepo(
_satelliteSelection.value = ids
}
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
override fun setSelectedSatModes(modes: List<String>) {
val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedSatModes, modesString) }
_satelliteModeSelection.value = modes
}
private fun getSelectedIds(): List<Int> {
@@ -117,10 +139,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma)
private fun getSelectedSatModes(): List<String> {
val modesString = preferences.getString(keySelectedSatModes, null)
if (modesString.isNullOrEmpty()) return emptyList()
return modesString.split(separatorComma).sorted()
}
//endregion
@@ -135,7 +157,6 @@ class SettingsRepo(
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
@@ -146,16 +167,13 @@ class SettingsRepo(
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 invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
invertAosTimeWindow
)
}
//endregion
@@ -235,25 +253,6 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState())
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 {
putInt(keyNumberOfSatellites, state.numberOfSatellites)
@@ -298,6 +297,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
@@ -307,6 +310,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -315,7 +319,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -327,6 +331,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -356,6 +362,8 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
}
new
}
@@ -372,7 +380,9 @@ class SettingsRepo(
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
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
@@ -381,21 +391,73 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
// Normalize the enabled lists so they are positionally aligned with the URL lists.
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
// invariant intact even when a default empty list is used to construct the model.
val normalized = settings.copy(
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
)
preferences.edit {
putBoolean(keyUseCustomTle, settings.useCustomTLE)
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversUrl, settings.transceiversUrl)
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
}
_dataSourcesSettings.value = settings
_dataSourcesSettings.value = normalized
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
)
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 parseSources(
storedUrls: String?,
storedEnabled: String?,
defaults: List<String>
): Pair<List<String>, List<Boolean>> {
if (storedUrls == null) return defaults to defaults.map { true }
val urls = storedUrls.split(separatorUrl)
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
val filteredUrls = mutableListOf<String>()
val filteredFlags = mutableListOf<Boolean>()
urls.forEachIndexed { index, url ->
if (url.isNotBlank()) {
filteredUrls.add(url)
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
}
}
return filteredUrls to filteredFlags
}
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
if (flags.size >= urls.size) return flags.take(urls.size)
return flags + List(urls.size - flags.size) { true }
}
//endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
}
//endregion
//region # Radio control settings
@@ -435,5 +497,39 @@ class SettingsRepo(
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
)
private val keySatelliteOffsets = "satelliteOffsets"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
}
override fun setSatelliteOffset(catnum: Int, offset: String) {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
obj.toString()
} catch (_: Exception) {
"""{"$catnum": "$offset"}"""
}
preferences.edit { putString(keySatelliteOffsets, updated) }
}
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
override fun getAmSatCallsign(): String {
return preferences.getString(keyAmSatCallsign, "").orEmpty()
}
override fun setAmSatCallsign(callsign: String) {
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
}
//endregion
}
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
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 deleteEntries() = look4SatDao.deleteEntries()
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData(
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(
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() }
@@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios()
}
override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.insertRadios(radios.toFrameworkRadios())
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
}
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.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(
@@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
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() }
//endregion
@@ -20,11 +20,13 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver
import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
class RemoteSource(
@@ -43,15 +45,74 @@ class RemoteSource(
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
val code = response.code
response.close()
return@withContext NetworkResult(code, null)
}
// Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool
val body = response.body
if (body == null) {
response.close()
return@withContext NetworkResult(response.code, null)
}
NetworkResult(response.code, body.byteStream().buffered())
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
}
}
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
try {
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php")
.header("User-Agent", "Look4Sat")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
response.code to response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource submitAmSatReport exception: $exception")
null
}
}
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -41,6 +42,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest {
@@ -48,6 +50,10 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher()
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
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites"
@@ -62,7 +68,6 @@ class DatabaseRepoTest {
assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum)
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
}
@@ -91,10 +96,8 @@ class DatabaseRepoTest {
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
useCustomTLE = true,
useCustomTransceivers = false,
tleUrl = customCsvUrl,
transceiversUrl = ""
satelliteUrls = listOf(customCsvUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -102,33 +105,200 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
}
@Test
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
val primaryUrl = "https://example.com/primary.txt"
val secondaryUrl = "https://example.com/secondary.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[primaryUrl] = { validTleStream() }
networkStreams[secondaryUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(primaryUrl, secondaryUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
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 = """
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()
private fun validTleStream(): InputStream = """
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
""".trimIndent().byteInputStream()
private fun fresherTleStream(): InputStream = """
ISS
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
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()
}
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 {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getNetworkStream(url: String): NetworkResult {
val stream = networkStreams[url]?.invoke()
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
}
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
private class FakeLocalSource : ILocalSource {
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
@@ -136,6 +306,21 @@ private class FakeLocalSource : ILocalSource {
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>) {
insertedEntries += entries
}
@@ -150,25 +335,34 @@ private class FakeLocalSource : ILocalSource {
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
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
}
override suspend fun deleteRadios() {
insertedRadios.clear()
customRadios.clear()
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
}
}
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
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(
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))
@@ -176,7 +370,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -185,15 +379,15 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
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
@@ -203,11 +397,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
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) {
databaseState.value = state
@@ -221,14 +410,24 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
dataSourcesSettings.value = settings
}
override fun updateDataSourcesStatus(status: Map<String, Int>) {
dataSourcesStatus.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings(
useCustomTLE = false,
useCustomTransceivers = false,
tleUrl = "",
transceiversUrl = ""
satelliteUrls = emptyList(),
transceiversUrls = emptyList()
)
}
@@ -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
}
}
@@ -0,0 +1,17 @@
package com.rtbishop.look4sat.core.domain.model
/** One public AMSAT satellite status report submission. */
data class AmSatReportSubmission(
val name: String,
val report: String,
val callsign: String,
val gridSquare: String,
val reportedAtUtcMillis: Long
)
/** Result of submitting an AMSAT satellite status report. */
data class AmSatReportSubmitResult(
val success: Boolean,
val message: String = "",
val reportId: String? = null
)
@@ -0,0 +1,23 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
object Constants {
const val FREQ_OFFSET_MIN_HZ = -50_000L
const val FREQ_OFFSET_MAX_HZ = 50_000L
}
@@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName
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
data class SatRadio(
@SerialName("uuid") val uuid: String,
@SerialName("description") val info: String,
@SerialName("alive") val isAlive: Boolean,
@SerialName("downlink_low") val downlinkLow: Long?,
@SerialName("downlink_high") val downlinkHigh: Long?,
@SerialName("mode") val downlinkMode: String?,
@SerialName("uplink_low") val uplinkLow: Long?,
@SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?
@SerialName("description") val info: String = "",
@SerialName("alive") val isAlive: Boolean = true,
@SerialName("downlink_low") val downlinkLow: Long? = null,
@SerialName("downlink_high") val downlinkHigh: Long? = null,
@SerialName("mode") val downlinkMode: String? = null,
@SerialName("uplink_low") val uplinkLow: Long? = null,
@SerialName("uplink_high") val uplinkHigh: Long? = null,
@SerialName("uplink_mode") val uplinkMode: String? = null,
@SerialName("invert") val isInverted: Boolean = false,
@SerialName("norad_cat_id") val catnum: Int? = null
)
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 5 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 5 天(新→旧)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -29,8 +29,7 @@ data class PassesSettings(
val minElevation: Double,
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
val invertAosTimeWindow: Boolean = false
)
data class RCSettings(
@@ -42,6 +41,7 @@ data class RCSettings(
val frequencyAddress: String,
val frequencyPort: String,
val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String,
@@ -62,15 +62,26 @@ data class OtherSettings(
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
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(
val useCustomTLE: Boolean,
val useCustomTransceivers: Boolean,
val tleUrl: String,
val transceiversUrl: String
)
val satelliteUrls: 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(
val enabled: Boolean,
@@ -424,8 +424,12 @@ object CelestialComputer {
}
if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold
daynum = sunrise
// Phase 4: fast-forward through the day until sun drops back below threshold.
// Start from just after sunrise (small offset) so the sun is clearly above
// the threshold. This prevents a bug where Phase 3 converges to a point
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
// to the same time as sunrise, producing identical sunrise/sunset times.
daynum = sunrise + 0.001
sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
@@ -37,30 +37,42 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
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)
/** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
}
private 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
}
companion object {
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
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
/**
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
* freshness checks must always go through this conversion.
*/
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
}
}
}
@@ -0,0 +1,14 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
}
@@ -29,6 +29,7 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
@@ -39,6 +40,7 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
fun providePairedBluetoothDevices(): List<Pair<String, String>>
}
interface IContainerProvider {
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentTypes(): List<String>
fun getTypesList(): List<String>
fun getCurrentModes(): List<String>
fun getModesList(): List<String>
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 setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
@@ -29,12 +29,13 @@ import kotlinx.coroutines.flow.StateFlow
interface ISettingsRepo {
val appVersionName: String
val appVersionCode: Long
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
val selectedTypes: StateFlow<List<String>>
val selectedSatModes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>)
fun setSelectedTypes(types: List<String>)
fun setSelectedSatModes(modes: List<String>)
//endregion
//region # Passes filter settings
@@ -51,8 +52,6 @@ interface ISettingsRepo {
//region # Database update settings
val databaseState: StateFlow<DatabaseState>
fun getSatelliteTypesIds(types: List<String>): List<Int>
fun setSatelliteTypeIds(type: String, ids: List<Int>)
fun updateDatabaseState(state: DatabaseState)
//endregion
@@ -73,8 +72,23 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
}
@@ -26,11 +26,18 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem>
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 deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>)
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
suspend fun deleteRadios()
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
suspend fun deleteManagedRadios()
}
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
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 {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
}
@@ -18,37 +18,27 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter
// Ordered from the most specific to the most generic source: the first source that knows a
// satellite gets to name it, with the full catalog acting as the fallback.
val satelliteDataUrls = listOf(
"live.ariss.org/iss.txt",
"r4uab.ru/satonline.txt",
"mmccants.org/tles/classfd.zip",
"amsat.org/tle/current/nasabare.txt",
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"db.satnogs.org/api/tle/?format=3le"
)
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val transceiversDataUrls = listOf(
"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
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) {
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())
}
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val timestamp = values[2]
val year = timestamp.substring(0, 4)
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()
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
fun value(column: String) = values[columns.getValue(column)].trim()
fun optionalValue(column: String) =
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
OrbitalData(
name = name,
epoch = epoch,
meanmo = values[3].toDouble(),
eccn = values[4].toDouble(),
incl = values[5].toDouble(),
raan = values[6].toDouble(),
argper = values[7].toDouble(),
meanan = values[8].toDouble(),
catnum = values[11].toInt(),
bstar = values[14].toDouble(),
ndot = values[15].toDouble()
name = value("OBJECT_NAME"),
epoch = parseTimestamp(value("EPOCH")),
meanmo = value("MEAN_MOTION").toDouble(),
eccn = value("ECCENTRICITY").toDouble(),
incl = value("INCLINATION").toDouble(),
raan = value("RA_OF_ASC_NODE").toDouble(),
argper = value("ARG_OF_PERICENTER").toDouble(),
meanan = value("MEAN_ANOMALY").toDouble(),
catnum = parseCatnum(value("NORAD_CAT_ID")),
bstar = optionalValue("BSTAR"),
ndot = optionalValue("MEAN_MOTION_DOT")
)
}.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 {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim(),
name = tle[0].trim().removePrefix("0 "),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
@@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
raan = line2.substring(17, 25).toDouble(),
argper = line2.substring(34, 42).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()),
ndot = line1.substring(33, 43).trim().toDouble()
)
@@ -0,0 +1,141 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
*
* For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, this computes the other:
*
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
*/
object DopplerFrequencyCalculator {
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
* Returns null if the transponder is not a linear passband type.
*/
fun computeUplinkFromDownlink(
downlinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
}
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos,
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
}
/**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
* Returns null if the transponder is not a linear passband type.
*/
fun computeDownlinkFromUplink(
uplinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
return orbitalPos.getDownlinkFreq(baseDownlink)
}
/**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
*/
fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos,
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
}
/** True if this transponder supports linear passband mapping. */
fun isLinearTransponder(transponder: SatRadio): Boolean {
val upLow = transponder.uplinkLow
val upHigh = transponder.uplinkHigh
val downLow = transponder.downlinkLow
val downHigh = transponder.downlinkHigh
return upLow != null && upHigh != null && downLow != null && downHigh != null
&& upLow != upHigh && downLow != downHigh
}
/**
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for named linear
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
}
}
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
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 {
// val hexChars = "0123456789ABCDEF"
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
@@ -18,10 +18,12 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.math.abs
@ExperimentalCoroutinesApi
class DataParserTest {
@@ -60,8 +62,49 @@ class DataParserTest {
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream)
assert(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816)
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
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
@@ -239,4 +282,10 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023)
}
}
@@ -0,0 +1,273 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DopplerFrequencyCalculatorTest {
private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
downHigh: Long? = 435_500_000L,
inverted: Boolean = false,
info: String = "Linear Transponder",
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = uuid, info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
)
private fun fmTransponder() = SatRadio(
uuid = "fm", info = "FM Repeater", isAlive = true,
downlinkLow = 435_600_000L, downlinkHigh = null,
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
uplinkMode = "FM", isInverted = false, catnum = 99999
)
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
this.distanceRate = distanceRateKmS
}
@Test
fun isLinearTransponder_returnsTrueForLinear() {
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
}
@Test
fun isLinearTransponder_returnsFalseForFM() {
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
}
@Test
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
val xpdr = linearTransponder(downHigh = null)
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
}
@Test
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeDownlinkFromUplink_linear_noDoppler() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
assertNotNull(downlink)
assertEquals(435_200_000L, downlink)
}
@Test
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
val xpdr = linearTransponder()
val orbitalPos = pos(7.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertTrue(uplink!! > 145_200_000L)
}
@Test
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(435_202_500L, downlink)
}
@Test
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = 435_202_500L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeOffsetRoundTrip_handlesNegativeOffset() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(435_197_500L, downlink)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = downlink!!,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_invertedTransponder() {
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertEquals(145_300_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_roundTrip() {
val xpdr = linearTransponder()
val orbitalPos = pos(3.5)
val originalDownlink = 435_250_000L
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
assertNotNull(uplink)
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink)
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000)
}
}
@@ -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.RowScope
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -47,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.compositionLocalOf
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.TextDecoration
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.unit.Dp
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()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
ElevatedCard(
modifier = modifier
@@ -212,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
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
@@ -227,8 +239,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
}
@Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
fun IconCard(
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) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
}
@@ -284,51 +301,86 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
)
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) {
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
content()
}
}
@Composable
fun SharedDialog(
fun InfoDialog(
title: String,
onDismissRequest: () -> Unit,
onCancel: (() -> Unit)? = null,
onAccept: (() -> Unit)? = null,
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
onDismiss: () -> Unit,
onAccept: () -> Unit,
extraAction: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onDismissRequest) { padding ->
DialogShell(onDismissRequest = onDismiss) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
if (onCancel != null) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
}
Text(
text = title,
fontSize = titleFontSize.sp,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = titleTextAlign,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
extraAction?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
}
}
@Composable
fun ConfirmDialog(
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onCancel) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(horizontal = 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))
}
}
@@ -340,6 +392,9 @@ private fun DialogShell(
) {
val padding = LocalSpacing.current.large
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 {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
@@ -357,7 +412,7 @@ private fun DialogShell(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = null,
shape = MaterialTheme.shapes.medium,
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
scrimColor = Color.Black.copy(alpha = 0.64f)
) {
Column(
@@ -365,6 +420,7 @@ private fun DialogShell(
verticalArrangement = Arrangement.spacedBy(padding),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.nestedScroll(stopSheetFling)
) {
content(padding)
@@ -374,11 +430,11 @@ private fun DialogShell(
@Composable
fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
@Composable
fun hasEnoughWidth(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
@Composable
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 small = 8.dp
val medium = 12.dp
val large = 16.dp
val large = 12.dp
val extraLarge = 24.dp
}
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04),
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0),
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212),
// onTertiary = Color(0xFF121212),
// tertiaryContainer = Color(0xFF121212),
// onTertiaryContainer = Color(0xFF121212),
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
onTertiary = Color(0xFF000000),
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@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
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</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,9 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
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>
@@ -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>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="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>
@@ -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,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>
<!-- Satellites screen -->
<string name="sat_type_hint">Tipo: %s</string>
<string name="sat_type_title">Seleccionar tipo de satélite</string>
<string name="sat_type_hint">Modos: %s</string>
<string name="sat_type_title">Seleccionar modos</string>
<string name="sat_search_hint">Id - Nombre</string>
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar 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">
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>
@@ -26,7 +30,7 @@
Esta aplicación incluye más de 9000 satélites.
No tiene sentido rastrearlos todos a la vez.
\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 -->
<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_invert_time">Invertir ventana AOS</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_altitude">Altitud: %.0f km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<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_success">Ubicación actualizada con éxito</string>
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
<string name="prefs_station_title">Ubicación de la estación</string>
<string name="prefs_station_lat_text">Latitud de estación</string>
<string name="prefs_station_lon_text">Longitud de estación</string>
<string name="prefs_locator_title">Localizador QTH</string>
<string name="prefs_locator_text">Posición por localizador</string>
<string name="prefs_data_title">Datos satelitales</string>
<string name="prefs_data_entries">Satélites: %s</string>
@@ -110,39 +114,57 @@
<string name="prefs_data_clear">Vaciar</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_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_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Salida de datos</string>
<string name="prefs_net_output">Red</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="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Control de radio</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_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_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida Bluetooth</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_output_hint">Formato</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_output_hint">Formato</string>
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
<string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Actualización automática</string>
<string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<string name="nav_prefs">Настройки</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Выберите тип спутника</string>
<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>
которые вас интересуют, используя поиск и выбор режимов.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фильтровать пролеты</string>
@@ -34,7 +38,7 @@
<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_modes_title">Выберите режимы</string>
<string name="pass_elevation">Элевация: %.1f°</string>
<string name="pass_altitude">Высота: %d км</string>
<string name="pass_empty_list_message">
@@ -95,11 +99,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
<string name="prefs_loc_success">Позиция успешно обновлена</string>
<string name="prefs_station_title">Настройки местоположения</string>
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
<string name="prefs_locator_title">Настройки локатора QTH</string>
<string name="prefs_locator_text">Введите позицию через локатор</string>
<string name="prefs_station_title">Положение станции</string>
<string name="prefs_station_lat_text">Широта станции</string>
<string name="prefs_station_lon_text">Долгота станции</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиция по локатору</string>
<string name="prefs_data_title">Данные спутников</string>
<string name="prefs_data_entries">Спутников: %s</string>
@@ -109,16 +113,35 @@
<string name="prefs_data_clear">Очистить</string>
<string name="prefs_data_clear_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_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
<string name="prefs_data_sources_title">Свои источники</string>
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
<string name="prefs_data_sources_sat_title">Данные спутников</string>
<string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Вывод данных</string>
<string name="prefs_data_output_title">Вывод</string>
<string name="prefs_net_output">Сеть</string>
<string name="prefs_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_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string>
@@ -126,22 +149,22 @@
<string name="prefs_net_frequency_address_hint">IP:Порт</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_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_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_perm_error">Нет разрешения использовать bluetooth</string>
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</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_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_low">Низко &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<string name="nav_prefs">පසුතල</string>
<!-- Satellites screen -->
<string name="sat_type_hint">වර්ගය: %s</string>
<string name="sat_type_title">චන්ද්‍රිකා ආකාරය තෝරන්න</string>
<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සෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
<!-- Passes screen -->
@@ -35,7 +39,7 @@
<string name="pass_filter_aos_time">AOS කවුළුව</string>
<string name="pass_filter_invert_time">AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න</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_altitude">උන්නතාශය: %d km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න</string>
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
<string name="prefs_locator_title">QTH locator පසුතල</string>
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
<string name="prefs_station_title">පොළ පිහිටීම</string>
<string name="prefs_station_lat_text">පොළ අක්‍ෂාංශය</string>
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
<string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_entries">චන්ද්‍රිකා: %s</string>
@@ -110,39 +114,57 @@
<string name="prefs_data_clear">පිරිසිදු ක°</string>
<string name="prefs_data_clear_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_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න</string>
<string name="prefs_data_sources_sat_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_sources_transceivers_title">සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_sat_hint">ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම්</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">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">දත්ත ප්‍රතිදානය</string>
<string name="prefs_net_output">ජාලය</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_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_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_net_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</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_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string>
<string name="prefs_net_perm_error">Network අවසර පරික්‍ෂා කරන්න</string>
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</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">Radar 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_low">අඩු &lt; %1$d°</string>
@@ -14,19 +14,23 @@
<string name="nav_prefs">Ayarlar</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
<string name="sat_type_title">Uydu türü seçin</string>
<string name="sat_type_hint">Modlar: %s</string>
<string name="sat_type_title">Modları seçin</string>
<string name="sat_search_hint">Id - Ad</string>
<string name="sat_search_clear">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_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_warning_title">Uyarı\!</string>
<string name="sat_warning_message">
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
Hepsini aynı anda takip etmek anlamsızdır.
\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 -->
<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_deep_space">DeepSpace (periyot &gt;225dk)</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_elevation">Yükseklik açısı: %.1f°</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_error">Geçersiz QTH konumlandırıcısı</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_lat_text">Yer istasyonunuzun enlemini girin</string>
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
<string name="prefs_station_title">İstasyon konumu</string>
<string name="prefs_station_lat_text">İstasyon enlemi</string>
<string name="prefs_station_lon_text">İstasyon boylamı</string>
<string name="prefs_locator_title">QTH konumlandırıcı</string>
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
<string name="prefs_data_title">Uydu verisi</string>
<string name="prefs_data_entries">Uydular: %s</string>
@@ -141,28 +145,35 @@
<string name="prefs_data_clear">Temizle</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_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_tle_switch">Özel TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_title">Özel kaynaklar</string>
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
<string name="prefs_data_sources_sat_title">Uydu verileri</string>
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_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_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<!-- Radio Control -->
<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_tx_device_hint">TX Radyo BT Adresi</string>
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
<string name="rc_tx_device_hint">TX 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_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_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Biçim</string>
@@ -170,22 +181,22 @@
<string name="prefs_net_frequency_address_hint">IP:Port</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_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_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_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
<string name="prefs_other_title">Diğer</string>
<string name="prefs_other_switch_utc">UTC saati</string>
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
<string name="prefs_other_switch_sweep">Radar taraması</string>
<string name="prefs_other_switch_sensors">Sensör</string>
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Налаштування</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Виберіть тип супутника</string>
<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>
@@ -26,7 +30,7 @@
У цьому додатку перелічено понад 9000 супутників.
Немає сенсу відстежувати їх усі одночасно.
\n\nЗавжди намагайтеся звузити список лише до тих,
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фільтр прольотів</string>
@@ -35,7 +39,7 @@
<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_modes_title">Виберіть режими</string>
<string name="pass_elevation">Піднес.: %.1f°</string>
<string name="pass_altitude">Висота: %.0f км</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
<string name="prefs_loc_success">Позицію успішно оновлено</string>
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
<string name="prefs_locator_title">Налаштування QTH локатора</string>
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
<string name="prefs_station_title">Позиція станції</string>
<string name="prefs_station_lat_text">Широта станції</string>
<string name="prefs_station_lon_text">Довгота станції</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиція за локатором</string>
<string name="prefs_data_title">Супутникові дані</string>
<string name="prefs_data_entries">Супутників: %s</string>
@@ -110,39 +114,57 @@
<string name="prefs_data_clear">Очистити</string>
<string name="prefs_data_clear_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_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
<string name="prefs_data_sources_sat_title">Дані супутників</string>
<string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Вивід даних</string>
<string name="prefs_net_output">Мережа</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вивід мережевих даних</string>
<string name="prefs_net_rotator_switch">Enable rotation output</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_address_hint">IP:Порт</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_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
<string name="prefs_bt_title">Вивід Bluetooth</string>
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_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_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_low">Низько &lt; %1$d°</string>
@@ -12,16 +12,59 @@
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新于:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">遥测/信标</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">冲突</string>
<string name="amsat_name">名称</string>
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
<string name="amsat_no_reports">此时间段无报告</string>
<string name="amsat_close">关闭</string>
<string name="amsat_status_heard">已听到</string>
<string name="amsat_status_tlm_only">仅遥测</string>
<string name="amsat_status_not_heard">未听到</string>
<string name="amsat_status_crew_active">乘组活跃</string>
<string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_confirm">确认上传</string>
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
<string name="amsat_upload_grid_none">未填写</string>
<string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string>
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
<string name="amsat_upload_active">活跃</string>
<string name="amsat_upload_tlm">遥测/信标</string>
<string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
<string name="sat_type_title">选择卫星类型</string>
<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>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
@@ -30,7 +73,7 @@
<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_modes_title">选择模式</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
@@ -56,6 +99,27 @@
<string name="radar_string_no">否</string>
<string name="radar_string_yes">是</string>
<string name="radar_visible">日照中</string>
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<!-- 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 -->
<string name="map_prev">上一个</string>
@@ -77,10 +141,16 @@
<string name="prefs_donate_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_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string>
@@ -88,11 +158,11 @@
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_station_title">站点位置</string>
<string name="prefs_station_lat_text">站点纬度</string>
<string name="prefs_station_lon_text">站点经度</string>
<string name="prefs_locator_title">QTH 定位</string>
<string name="prefs_locator_text">通过 QTH 设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
@@ -102,16 +172,34 @@
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_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_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
<string name="prefs_data_sources_title">自定义源</string>
<string name="prefs_data_sources_restore">恢复默认源</string>
<string name="prefs_data_sources_sat_title">卫星数据</string>
<string name="prefs_data_sources_transceivers_title">收发器数据</string>
<string name="prefs_data_sources_sat_hint">靠前优先:卫星名称</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_net_output">网络</string>
<string name="prefs_bt_output">蓝牙</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">网络数据输出</string>
<string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
<string name="rc_tx_name_hint">TX 电台名称</string>
<string name="rc_rx_name_hint">RX 电台名称</string>
<string name="rc_enable_switch">启用 CAT</string>
<string name="prefs_net_title">网络输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string>
@@ -119,7 +207,7 @@
<string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_bt_title">蓝牙数据输出</string>
<string name="prefs_bt_title">蓝牙输出</string>
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_rotator_output_hint">数据格式</string>
@@ -129,12 +217,14 @@
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
<string name="prefs_net_perm_error">请检查网络权限</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_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_other_compass_offset">雷达罗盘水平偏移量</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -151,4 +241,7 @@
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- Main screen -->
<string name="tracking_format">跟踪:%s</string>
</resources>
+117 -31
View File
@@ -12,21 +12,26 @@
<string name="nav_radar">Radar</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<string name="nav_status" translatable="false">AMSAT</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
<string name="sat_type_title">Select satellite type</string>
<string name="sat_type_hint">Modes: %s</string>
<string name="sat_type_title">Select modes</string>
<string name="sat_search_hint">Id - Name</string>
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear 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_warning_title">Warning\!</string>
<string name="sat_warning_message">
There are over 9000 satellites listed in this app.
It makes no sense to track them all at the same time.
\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 -->
<string name="pass_filter_title">Filter passes</string>
@@ -36,7 +41,7 @@
<string name="pass_filter_invert_time">Invert AOS window</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</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_elevation">Elevation: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -51,10 +56,55 @@
\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_message" translatable="false">
* Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
\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>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<string name="amsat_status_heard">Heard</string>
<string name="amsat_status_tlm_only">Telemetry Only</string>
<string name="amsat_status_not_heard">Not Heard</string>
<string name="amsat_status_crew_active">Crew Active</string>
<string name="amsat_upload">Upload</string>
<string name="amsat_upload_back">Back</string>
<string name="amsat_upload_report">Upload report to AMSAT</string>
<string name="amsat_upload_status">Status</string>
<string name="amsat_upload_callsign">Callsign</string>
<string name="amsat_upload_grid">Grid</string>
<string name="amsat_upload_time_now">Time: current UTC %s</string>
<string name="amsat_upload_submit">Submit to AMSAT</string>
<string name="amsat_upload_confirm">Upload</string>
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
<string name="amsat_upload_grid_none">Not provided</string>
<string name="amsat_upload_success">Report uploaded</string>
<string name="amsat_upload_failed">Upload failed: %s</string>
<string name="amsat_upload_callsign_required">Callsign is required</string>
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
<string name="amsat_upload_active">Active</string>
<string name="amsat_upload_tlm">TLM/Beacon</string>
<string name="amsat_upload_not_heard">Not Heard</string>
<string name="amsat_upload_crew_active">Crew Active</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
<string name="radar_notify">Notify</string>
@@ -80,6 +130,27 @@
<string name="radar_string_no">No</string>
<string name="radar_string_yes">Yes</string>
<string name="radar_visible">Visible</string>
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<!-- 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 -->
<string name="map_prev">Prev</string>
@@ -113,6 +184,12 @@
<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="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_time">d MMM yyyy - HH:mm:ss</string>
@@ -124,11 +201,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
<string name="prefs_loc_success">Position updated successfully</string>
<string name="prefs_station_title">Station position settings</string>
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_station_title">Station position</string>
<string name="prefs_station_lat_text">Station latitude</string>
<string name="prefs_station_lon_text">Station longitude</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">Set position via locator</string>
<string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string>
@@ -141,10 +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_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_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_sources_title">Data sources</string>
<string name="prefs_data_sources_restore">Restore default sources</string>
<string name="prefs_data_sources_sat_title">Satellites data</string>
<string name="prefs_data_sources_transceivers_title">Transceivers data</string>
<string name="prefs_data_sources_sat_hint">Top priority affects satellite names</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_net_output">Network</string>
@@ -153,38 +232,42 @@
<!-- Radio Control -->
<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_tx_device_hint">TX Radio BT Address</string>
<string name="rc_rx_device_hint">RX Radio BT Address</string>
<string name="rc_tx_device_hint">TX 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_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_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_bt_title">Bluetooth 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_device_hint">Device id</string>
<string name="prefs_bt_rotator_output_hint">Data format</string>
<string name="prefs_bt_rotator_device_hint">Device ID</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_device_hint">Device id</string>
<string name="prefs_bt_frequency_output_hint">Data format</string>
<string name="prefs_bt_frequency_device_hint">Device ID</string>
<string name="prefs_bt_frequency_output_hint">Format</string>
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_title">Other</string>
<string name="prefs_other_switch_utc">UTC time</string>
<string name="prefs_other_switch_update">Auto-update data</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Use sensors</string>
<string name="prefs_other_switch_night_mode">Night filter</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -203,4 +286,7 @@
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
<!-- Main screen -->
<string name="tracking_format">Tracking: %s</string>
</resources>
@@ -1,2 +1,5 @@
* Added required tweaks to support Android 17 (API 37)
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
* Tweaked sun calculations, added tests by atsunatsu (#241)
* 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.Paint
import android.graphics.RectF
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay
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:
* 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
* 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() {
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
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) {
if (shadow) return
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
val h = mapView.height
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,
// then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
var x = 0
while (x < w) {
// Get the geographic coordinate at the top and bottom of this column.
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
val lonRad = Math.toRadians(geoTop.longitude)
val cosLonDiff = cos(lonRad - sunLonRad)
// Top pixel geographic lat
val latTopRad = Math.toRadians(geoTop.latitude)
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
val latBotRad = Math.toRadians(geoBot.latitude)
// dot(sunVec, pointVec) < 0 → night
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
when {
dotTop < 0 && dotBot < 0 -> {
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
var hi = yBot
while (hi - lo > 1) {
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 dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.GeoPos
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.presentation.IconCard
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.isVerticalLayout
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.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
@@ -126,6 +129,21 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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)
}
@@ -283,19 +301,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private val markerPool = HashMap<String, Marker>()
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(
posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView,
action: (OrbitalObject) -> Unit
) {
try {
lastPositions = posMap
lastAction = action
// Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) {
lastMapView = mapView
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = 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
// causes osmdroid to detach shared Marker objects, making them invisible.
@@ -304,17 +352,48 @@ private fun setPositions(
}
folder.items.clear()
val activeNames = HashSet<String>(posMap.size)
posMap.forEach { (satellite, geoPos) ->
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
// 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
activeNames.add(name)
val marker = markerPool.getOrPut(name) {
Marker(mapView).apply {
setInfoWindow(null)
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
val pos = marker.position
if (pos != null) {
@@ -323,22 +402,32 @@ private fun setPositions(
} else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
}
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker)
}
// Evict markers for satellites no longer tracked
val iter = markerPool.keys.iterator()
while (iter.hasNext()) {
if (iter.next() !in activeNames) iter.remove()
}
// Evict markers that are no longer tracked or no longer visible
markerPool.keys.retainAll(activeNames)
visibleSats.clear()
} catch (e: Exception) {
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 {
iconCache[name]?.let { return it }
val labelRect = Rect()
@@ -376,9 +465,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
try {
val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size)
@@ -508,9 +596,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(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
}
private fun clearMapCaches() {
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
lastPositions = null
lastAction = null
lastMapView = null
}
@Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
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.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState(
val mapData: MapData? = null,
@@ -29,7 +28,7 @@ data class MapState(
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null,
val footprint: List<GeoPos>? = null,
val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0,
@@ -42,6 +41,7 @@ sealed interface MapAction {
data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
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.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
class MapViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -69,6 +72,9 @@ class MapViewModel(
private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L
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
init {
@@ -87,6 +93,7 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
@@ -124,7 +131,11 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject
viewModelScope.launch {
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()
getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow)
@@ -135,9 +146,11 @@ class MapViewModel(
else -> updateFreq
}
while (isActive) {
// Suspends while the map is off-screen instead of predicting into the void
isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis()
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
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 sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
@@ -294,7 +308,7 @@ class MapViewModel(
companion object {
/** 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 {
initializer {
@@ -58,20 +58,13 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
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.ElevationLowColor
import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.domain.source.Sources
import kotlin.math.roundToInt
private val allModes = listOf(
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
)
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
private const val dayMinutes = 24 * 60
private const val minuteStep = 15
@@ -147,7 +140,7 @@ internal fun PassesFilterDialog(
)
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(
title = stringResource(R.string.pass_filter_elev),
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
}
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(
columns = GridCells.Adaptive(240.dp),
modifier = Modifier
@@ -381,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allModes) { index, item ->
itemsIndexed(Sources.satelliteModes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
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.R
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.TimerRow
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.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -135,26 +135,14 @@ private fun PassesScreen(
}
if (uiState.shouldSeeWhatsNew) {
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
SharedDialog(
title = stringResource(R.string.pass_whatsnew_title),
onDismissRequest = dismiss,
onAccept = dismiss,
titleFontSize = 18
) { padding ->
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
}
WhatsNewDialog(onDismiss = dismiss)
}
ScreenColumn(
topBar = { isVerticalLayout ->
TopBar(
isVerticalLayout = isVerticalLayout,
startAction = {
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
@@ -163,7 +151,7 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
}
)
}
@@ -290,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)
@Composable
private fun DeepSpacePassPreview() {
@@ -322,7 +315,7 @@ private fun PassItem(
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
@@ -34,6 +34,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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.isActive
import kotlinx.coroutines.launch
@@ -41,6 +44,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -60,7 +64,7 @@ class PassesViewModel(
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
modes = settingsRepo.passesSettings.value.selectedModes,
modes = settingsRepo.selectedSatModes.value,
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
)
)
@@ -86,33 +90,43 @@ class PassesViewModel(
}
}
}
// Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch {
satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) {
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
val grouped = groupPasses(processed, isUtc)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
sunTimes = sunTimes,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
}
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(modes = modes) }
}
}
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
// computations run once per restart, progress/countdown are calculated every second.
viewModelScope.launch {
combine(
satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
val filtered = if (showDeepSpace) allPasses
else allPasses.filter { !it.isDeepSpace }
// Expensive: recompute sun times once per items/UTC change, not every second
val sunTimes = computeSunTimes(filtered, isUtc)
_uiState.update { it.copy(sunTimes = sunTimes) }
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow)
val grouped = groupPasses(processed, isUtc)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000.milliseconds)
}
}
}
}
fun onAction(action: PassesAction) {
@@ -127,21 +141,9 @@ class PassesViewModel(
aosStartMinute = action.aosStartMinute,
aosEndMinute = action.aosEndMinute,
invertAosTimeWindow = action.invertAosTimeWindow,
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
showDeepSpace = action.showDeepSpace
)
is PassesAction.FilterRadios -> setModesFilter(action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog ->
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
@@ -152,12 +154,28 @@ class PassesViewModel(
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
val locale = displayLocale()
val pattern = if (locale.language == Locale.CHINESE.language) {
"yyyy'年'M'月'd'日' EEEE"
} else {
"EEE, dd MMM yyyy"
}
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
}
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
val sdfDate = dateFormat(tz)
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
@@ -180,7 +198,7 @@ class PassesViewModel(
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val sdfDate = dateFormat(tz)
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
val deepSpace = passes.filter { it.isDeepSpace }
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
@@ -235,8 +253,7 @@ class PassesViewModel(
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
showDeepSpace: Boolean,
modes: List<String>
showDeepSpace: Boolean
) = viewModelScope.launch {
settingsRepo.setPassesSettings(
PassesSettings(
@@ -245,8 +262,7 @@ class PassesViewModel(
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
modes
invertAosTimeWindow
)
)
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
@@ -259,10 +275,10 @@ class PassesViewModel(
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
invertAosTimeWindow = invertAosTimeWindow,
showDeepSpace = showDeepSpace,
modes = modes
showDeepSpace = showDeepSpace
)
}
val modes = settingsRepo.selectedSatModes.value
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = hoursAhead,
@@ -274,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 {
val settings = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(
@@ -283,7 +313,7 @@ class PassesViewModel(
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
modes = settingsRepo.selectedSatModes.value
)
}
@@ -31,9 +31,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -45,6 +48,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -59,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -74,6 +79,7 @@ import kotlinx.coroutines.launch
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Calculator("Calculator"),
Sstv("SSTV")
}
@@ -112,6 +118,7 @@ private fun RadarScreen(
Column(
modifier = Modifier
.layoutPadding()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
@@ -150,16 +157,31 @@ private fun PagerCard(
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
val lastPage = pages.lastIndex
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
}
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
selected = pagerState.currentPage == index,
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
@@ -176,6 +198,13 @@ private fun PagerCard(
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Calculator -> CalculatorPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
onAction = onAction,
calculatorOffsetKHz = uiState.calculatorOffsetKHz
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
@@ -225,6 +254,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
shouldFlipRadar = uiState.shouldFlipRadar,
modifier = Modifier.align(Alignment.Center),
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
@@ -58,11 +58,13 @@ data class RadarState(
val satTrack: List<OrbitalPos> = emptyList(),
val shouldShowSweep: Boolean = false,
val shouldUseCompass: Boolean = false,
val shouldFlipRadar: Boolean = false,
val sunPosition: CelestialComputer.SunPosition? = null,
val moonPosition: CelestialComputer.MoonPosition? = null,
val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState()
val sstv: SstvSubState = SstvSubState(),
val calculatorOffsetKHz: String = ""
)
enum class SstvStatus { Idle, Recording }
@@ -96,4 +98,7 @@ sealed interface RadarAction {
data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
}
@@ -74,6 +74,7 @@ fun RadarViewCompose(
azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean,
shouldUseCompass: Boolean,
shouldFlipRadar: Boolean,
modifier: Modifier = Modifier,
sunPosition: CelestialComputer.SunPosition? = null,
moonPosition: CelestialComputer.MoonPosition? = null,
@@ -125,7 +126,9 @@ fun RadarViewCompose(
cachedSweepBrush = makeSweepBrush(center, 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) }
drawRadar(radius, radarColor)
drawElevationLabels(radius, primaryColor, measurer)
@@ -65,11 +65,14 @@ class RadarViewModel(
private val showToast: IShowToast
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var stationPos = settingsRepo.stationPosition.value
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 sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null
private var sensorCollectionJob: Job? = null
// Celestial positions change slowly, recompute at most once per minute
private var lastCelestialUpdateMs = 0L
@@ -88,33 +91,67 @@ class RadarViewModel(
val uiState: StateFlow<RadarState> = _uiState
init {
collectCompassSensor()
collectSettingsChanges()
collectSettingsChanges() // also handles initial sensor subscription
collectStationPositionChanges()
collectPassAndStartTickLoop()
collectRadioTrackingState()
}
private fun collectCompassSensor() {
if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch {
// Starts sensor collection if not already running.
private fun startSensorCollection() {
if (sensorCollectionJob?.isActive == true) return
sensorCollectionJob = viewModelScope.launch {
sensorsRepo.enableSensor()
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) }
}
}
}
// Stops sensor collection and disables the hardware sensor.
private fun stopSensorCollection() {
sensorCollectionJob?.cancel()
sensorCollectionJob = null
sensorsRepo.disableSensor()
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
settingsRepo.otherSettings.collect { settings ->
compassOffset = settings.radarCompassOffset
compassOffsetElev = settings.radarCompassOffsetElev
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
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) }
}
}
}
}
}
@@ -213,7 +250,7 @@ class RadarViewModel(
}
override fun onCleared() {
sensorsRepo.disableSensor()
stopSensorCollection()
}
fun onAction(action: RadarAction) {
@@ -223,7 +260,17 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
// Load the saved offset for the newly selected satellite
val offsetKHz = if (!isTogglingOff) {
transponders.find { it.uuid == action.uuid }
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
} else ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = newUuid),
calculatorOffsetKHz = offsetKHz
)
}
// Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand
if (!isTogglingOff) {
@@ -272,6 +319,15 @@ class RadarViewModel(
sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
}
is RadarAction.ChangeCalculatorOffset -> {
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
transponders.find { it.uuid == uuid }?.catnum
}
if (catnum != null) {
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
}
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
}
}
}
@@ -399,7 +455,7 @@ class RadarViewModel(
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
// - 0.50-0.60: Conservative, best for noisy environments
private const val SSTV_TARGET_RMS = 0.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.
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
@@ -422,7 +478,7 @@ class RadarViewModel(
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
// Ask users to compare both if they report line-skipping/compression artifacts.
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
// Debug: Return scope visualization in frames (increases per-frame memory usage).
private const val SSTV_INCLUDE_SCOPE = false
@@ -434,7 +490,6 @@ class RadarViewModel(
218.1, 225.7, 233.6, 241.8, 250.3
)
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
RadarViewModel(
@@ -49,14 +49,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.OutlinedText
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import kotlin.math.roundToInt
@@ -78,12 +79,12 @@ internal fun SstvPage(
) {
Text(text = "🎙️", fontSize = 48.sp)
Text(
text = "Microphone access needed",
text = stringResource(R.string.sstv_mic_permission),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
)
Button(onClick = requestMicPermission) {
Text("Grant permission")
Text(stringResource(R.string.sstv_grant_permission))
}
}
}
@@ -96,14 +97,14 @@ internal fun SstvPage(
if (showModeDialog.value) {
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
val dismiss = { showModeDialog.value = false }
SharedDialog(
title = "SSTV Mode",
onDismissRequest = dismiss,
onAccept = dismiss
) { _ ->
ConfirmDialog(
title = stringResource(R.string.sstv_mode_title),
onAccept = dismiss,
onCancel = dismiss
) {
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.7f)
.fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
@@ -167,7 +168,7 @@ internal fun SstvPage(
)
} else {
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,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center)
@@ -196,7 +197,7 @@ internal fun SstvPage(
) {
// Doppler-corrected downlink frequency hint
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,
fontWeight = FontWeight.Bold,
fillColor = MaterialTheme.colorScheme.primary,
@@ -227,7 +228,7 @@ internal fun SstvPage(
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Mode: ${sstv.selectedMode}",
text = stringResource(R.string.sstv_mode_format, sstv.selectedMode),
fontSize = 14.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface,
@@ -21,14 +21,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -39,14 +43,23 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -54,12 +67,17 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -104,6 +122,73 @@ fun TransceiversPage(
}
}
@Composable
fun CalculatorPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
onAction: (RadarAction) -> Unit,
calculatorOffsetKHz: String = "",
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
.let(DopplerFrequencyCalculator::deduplicateTransponders)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
?: calculatorTransceivers.firstOrNull()
}
if (selectedTransceiver == null) {
EmptyTransceiversContent(modifier)
return
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (calculatorTransceivers.size > 1) {
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
item {
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -172,12 +257,8 @@ private fun TransceiverItem(
.rotate(if (isExpanded) 270f else 90f)
)
}
// Title with mode
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
val fullTitle = "$title ($mode)"
Text(
text = fullTitle,
text = transceiverTitle(radio),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -322,7 +403,7 @@ private fun ExpandedRadioControl(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "TX Base: ",
text = stringResource(R.string.radar_tx_base),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -369,16 +450,17 @@ private fun ExpandedRadioControl(
)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 5,
modifier = Modifier.fillMaxWidth()
) {
val chipModifier = Modifier.width(64.dp)
val chipModifier = Modifier.weight(1f)
FilterChip(
selected = radioControl.ctcssTone == null,
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
label = {
Text(
text = "Off",
text = stringResource(R.string.radar_off),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
@@ -410,19 +492,19 @@ private fun ExpandedRadioControl(
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
CardButton(
onClick = { onAction(RadarAction.ConnectRadios) },
text = "Connect",
text = stringResource(R.string.radar_connect),
modifier = Modifier.weight(1f)
)
} else {
CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) },
text = "Disconnect",
text = stringResource(R.string.radar_disconnect),
modifier = Modifier.weight(1f)
)
}
CardButton(
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)
)
}
@@ -436,6 +518,362 @@ private fun ExpandedRadioControl(
}
}
private enum class EditedField { TX, PASSBAND, RX }
@Composable
private fun DopplerFrequencyCalculator(
transponder: SatRadio,
orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var txFrequencyHz by remember { mutableStateOf(0L) }
var rxFrequencyHz by remember { mutableStateOf(0L) }
var passbandPosition by remember { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
val txLow = transponder.uplinkLow ?: return
val txHigh = transponder.uplinkHigh ?: return
val rxLow = transponder.downlinkLow ?: return
val rxHigh = transponder.downlinkHigh ?: return
val txRange = txHigh - txLow
val rxRange = rxHigh - rxLow
if (txRange <= 0L || rxRange <= 0L) return
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
}
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
LaunchedEffect(orbitalPos) {
if (lastEditedField == EditedField.TX) {
// TX 是锚点,不变;重算 RX
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} else if (lastEditedField == EditedField.RX) {
// RX 是锚点,不变;重算 TX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
passbandPosition
} else {
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
}
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
}
}
Column(
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 左半边: TX + RX 按钮
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
onClick = {
when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
// 右半边: Offset 输入框(带细线边框)
Box(
modifier = Modifier
.weight(1f)
.height(48.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = onOffsetChange,
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = stringResource(R.string.radar_doppler_offset_hint),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
// TX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = txSliderValue,
onValueChange = { norm ->
val rawFreq = txLow + (txRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
// RX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
}
}
@Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let {
@@ -451,5 +889,12 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
)
}
private fun transceiverTitle(radio: SatRadio): String {
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
return "$title ($mode)"
}
private val FREQ_ADJUSTMENTS =
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.tooling.preview.Preview
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.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Preview(showBackground = true)
@Composable
private fun MultiTypesDialogPreview() {
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
private fun MultiModesDialogPreview() {
val modes = listOf("FM", "APT", "SSTV", "BPSK")
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
}
@Composable
internal fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
internal fun MultiModesDialog(
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) {
val selected = remember { mutableStateOf(types.toSet()) }
val toggle = { type: String ->
selected.value = if (type in selected.value) selected.value - type else selected.value + type
val selected = remember { mutableStateOf(modes.toSet()) }
val toggle = { mode: String ->
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
}
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(
columns = GridCells.Adaptive(240.dp),
modifier = Modifier
@@ -68,7 +68,7 @@ internal fun MultiTypesDialog(
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allTypes) { index, item ->
itemsIndexed(allModes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
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.EmptyListCard
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.PrimaryIconCard
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.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -87,27 +88,27 @@ private fun SatellitesScreen(
navigateUp: () -> Unit
) {
if (uiState.isDialogShown) {
MultiTypesDialog(
allTypes = uiState.typesList,
types = uiState.currentTypes,
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
accept = { onAction(SatellitesAction.SelectTypes(it)) }
MultiModesDialog(
allModes = uiState.modesList,
modes = uiState.currentModes,
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
accept = { onAction(SatellitesAction.SelectModes(it)) }
)
}
if (uiState.shouldSeeWarning) {
val dismiss = { onAction(SatellitesAction.DismissWarning) }
SharedDialog(
InfoDialog(
title = stringResource(R.string.sat_warning_title),
onDismissRequest = dismiss,
onAccept = dismiss,
titleFontSize = 18
) { padding ->
onDismiss = dismiss,
onAccept = dismiss
) {
Text(
text = stringResource(R.string.sat_warning_message),
fontSize = 16.sp,
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)) {
if (isVerticalLayout()) {
TopBar {
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
ModeCard(
modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f)
)
PrimaryIconCard(
@@ -156,9 +157,9 @@ private fun SatellitesScreen(
resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd }
)
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
ModeCard(
modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f)
)
SearchBar(
@@ -240,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
}
@Composable
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
ElevatedCard(modifier = modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -251,12 +252,12 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_tags),
painter = painterResource(id = R.drawable.ic_radios),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.sat_type_hint, typesText),
text = stringResource(R.string.sat_type_hint, modesText),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
@@ -322,9 +323,54 @@ private fun SatellitesPreview() {
@Composable
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)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
stickyHeader(key = "sat_summary_header") {
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 shouldSeeWarning: Boolean = false,
val itemsList: List<SatItem> = emptyList(),
val currentTypes: List<String> = emptyList(),
val typesList: List<String> = emptyList()
val currentModes: List<String> = emptyList(),
val modesList: List<String> = emptyList()
)
sealed interface SatellitesAction {
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction
data object ToggleTypesDialog : SatellitesAction
data class SelectModes(val modes: List<String>) : SatellitesAction
data object ToggleModesDialog : SatellitesAction
data object UnselectAll : SatellitesAction
}
@@ -35,13 +35,13 @@ class SatellitesViewModel(
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val defaultTypes = selectionRepo.getCurrentTypes()
private val defaultModes = selectionRepo.getCurrentModes()
private val _uiState = MutableStateFlow(
SatellitesState(
isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
currentTypes = defaultTypes,
typesList = selectionRepo.getTypesList()
currentModes = defaultModes,
modesList = selectionRepo.getModesList()
)
)
val uiState: StateFlow<SatellitesState> = _uiState
@@ -49,7 +49,7 @@ class SatellitesViewModel(
init {
viewModelScope.launch {
selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes)
selectionRepo.setModes(defaultModes)
selectionRepo.getEntriesFlow().collectLatest { items ->
_uiState.update { it.copy(isLoading = false, itemsList = items) }
}
@@ -59,6 +59,11 @@ class SatellitesViewModel(
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
}
}
viewModelScope.launch {
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(currentModes = modes) }
}
}
}
fun onAction(action: SatellitesAction) {
@@ -68,8 +73,8 @@ class SatellitesViewModel(
is SatellitesAction.SearchFor -> searchFor(action.query)
SatellitesAction.SelectAll -> selectAll(true)
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
is SatellitesAction.SelectTypes -> selectTypes(action.types)
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
is SatellitesAction.SelectModes -> selectModes(action.modes)
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
SatellitesAction.UnselectAll -> selectAll(false)
}
}
@@ -86,12 +91,12 @@ class SatellitesViewModel(
selectionRepo.setSelection(listOf(id), isTicked.not())
}
private fun selectTypes(types: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types)
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
private fun selectModes(modes: List<String>) = viewModelScope.launch {
selectionRepo.setModes(modes)
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
}
private fun toggleTypesDialog() {
private fun toggleModesDialog() {
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
}
@@ -17,41 +17,66 @@
*/
package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings
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.DragReorderState
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
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.dragHandle
import com.rtbishop.look4sat.core.presentation.dragLift
import com.rtbishop.look4sat.core.presentation.rememberDragReorderState
import com.rtbishop.look4sat.core.presentation.rememberDragRowState
@Preview(showBackground = true)
@Composable
@@ -65,19 +90,28 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
val titleText = stringResource(id = R.string.prefs_station_title)
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large)
) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -100,12 +134,18 @@ private fun LocatorDialogPreview() {
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) }
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(
value = locator.value,
onValueChange = { locator.value = it },
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))
}
@@ -116,11 +156,19 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
private fun TransceiversDialogPreview() {
MainTheme {
DataSourcesDialog(
useCustomTle = true,
useCustomTransceivers = true,
tleUrl = "https://example.com/tle.txt",
transceiversUrl = "https://example.com/tx.json",
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
satelliteUrls = listOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"amsat.org/tle/current/nasabare.txt"
),
transceiversUrls = listOf(
"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 = {},
onImportTransceivers = {},
onDismiss = {},
@@ -129,113 +177,254 @@ private fun TransceiversDialogPreview() {
}
}
/**
* 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
fun DataSourcesDialog(
useCustomTle: Boolean,
useCustomTransceivers: Boolean,
tleUrl: String,
transceiversUrl: String,
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
satelliteUrls: List<String>,
transceiversUrls: List<String>,
satelliteEnabled: List<Boolean>,
transceiversEnabled: List<Boolean>,
statusCodes: Map<String, Int>,
onImportTle: () -> Unit,
onImportTransceivers: () -> Unit,
onDismiss: () -> Unit,
onSave: (Boolean, Boolean, String, String) -> Unit
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
) {
val padding = LocalSpacing.current.large
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
val onAccept = {
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
onDismiss()
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satEntries = remember {
satelliteUrls.mapIndexed { i, url ->
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
}.toMutableStateList()
}
val onCancel = { onDismiss() }
SharedDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onCancel,
onAccept = onAccept,
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CardButton(
onClick = {
onImportTle()
onDismiss()
},
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = {
onImportTransceivers()
onDismiss()
},
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
Switch(
checked = isEnabledCustomTle.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTle.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTle.value = true },
{ isEnabledCustomTle.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTle.value,
onValueChange = { urlTle.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
val txEntries = remember {
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 satFiltered = satEntries.filter { it.url.isNotBlank() }
val txFiltered = txEntries.filter { it.url.isNotBlank() }
try {
onSave(
satFiltered.map { it.url },
txFiltered.map { it.url },
satFiltered.map { it.enabled },
txFiltered.map { it.enabled }
)
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
Switch(
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTransceivers.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTransceivers.value = true },
{ isEnabledCustomTransceivers.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
)
Spacer(modifier = Modifier.height(12.dp))
} finally {
onDismiss()
}
}
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),
onCancel = onDismiss,
onAccept = onAccept,
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxHeight(0.84f)
.padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 0.dp)
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { onImportTle(); onDismiss() },
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onImportTransceivers(); onDismiss() },
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
}
sourceSection(
sectionKey = "sat",
label = satTitle,
hint = satHint,
entries = satEntries,
dragState = dragState,
statusCodes = statusCodes,
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(
sectionKey = "tx",
label = transceiversTitle,
hint = transceiversHint,
entries = txEntries,
dragState = dragState,
statusCodes = statusCodes,
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) }
)
}
}
}
private fun LazyListScope.sourceSection(
sectionKey: String,
label: String,
hint: String,
entries: SnapshotStateList<SourceEntry>,
dragState: DragReorderState,
statusCodes: Map<String, Int>,
onAdd: () -> Unit,
onRestore: () -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
onToggle: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit
) {
item {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
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(
text = hint,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.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)
@@ -252,6 +441,7 @@ fun PreviewNetworkOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -261,7 +451,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> }
onSave = { _, _, _, _, _, _, _, _, _ -> }
)
}
}
@@ -272,7 +462,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit,
onSave: (
Boolean, String, String, String,
Boolean, String, String, String
Boolean, String, String, String, Long
) -> Unit
) {
val padding = LocalSpacing.current.large
@@ -286,16 +476,19 @@ fun NetworkOutputDialog(
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
}
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
onSave(
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
)
onDismiss()
}
SharedDialog(
ConfirmDialog(
title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss,
onAccept = onAccept
@@ -324,6 +517,27 @@ fun NetworkOutputDialog(
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
)
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyOffsetHz.value,
onValueChange = { frequencyOffsetHz.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
trailingIcon = {
IconButton(
onClick = { frequencyOffsetHz.value = "0" },
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
)
Spacer(modifier = Modifier.height(12.dp))
}
}
@@ -352,6 +566,7 @@ fun PreviewBluetoothOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -377,11 +592,15 @@ fun BluetoothOutputDialog(
) {
val padding = LocalSpacing.current.large
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 frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val frequencyState =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = {
onSave(
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
@@ -389,7 +608,7 @@ fun BluetoothOutputDialog(
)
onDismiss()
}
SharedDialog(
ConfirmDialog(
title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss,
onAccept = onAccept
@@ -470,23 +689,22 @@ private fun OutputChannelSection(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RadioControlDialog(
initialSettings: RadioControlSettings,
pairedBluetoothDevices: List<Pair<String, String>>,
onDismiss: () -> Unit,
onSave: (RadioControlSettings) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val padding = LocalSpacing.current.large
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val padding = LocalSpacing.current.large
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
@@ -496,157 +714,157 @@ fun RadioControlDialog(
if (!isIcom && splitMode.value) splitMode.value = false
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 (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 = {
onSave(
RadioControlSettings(
enabled = enabled.value,
radioModel = radioModel.value,
enabled = enabled.value,
radioModel = radioModel.value,
txRadioAddress = txAddress.value,
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
txRadioName = txName.value,
rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue,
splitMode = splitMode.value
txRadioName = txName.value,
rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue,
splitMode = splitMode.value
)
)
onDismiss()
}
SharedDialog(
title = stringResource(R.string.rc_settings_title),
ConfirmDialog(
title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss,
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
// Enable switch
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.rc_enable_switch))
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
}
Spacer(modifier = Modifier.height(6.dp))
// Radio model — FlowRow so chips wrap on small screens
Text(
text = stringResource(R.string.rc_radio_model),
text = stringResource(R.string.rc_radio_model),
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 ->
FilterChip(
selected = radioModel.value == model,
onClick = { radioModel.value = model },
label = { Text(model, fontSize = 12.sp) },
enabled = enabled.value
onClick = { radioModel.value = model },
label = {
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))
// IC-705 split-mode toggle (only shown for IC-705)
if (isIcom) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
Text(
text = "Use VFO-A/B split on one IC-705 instead of two radios",
fontSize = 12.sp,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = splitMode.value,
onCheckedChange = { splitMode.value = it },
enabled = enabled.value
)
}
Spacer(modifier = Modifier.height(6.dp))
val isSplitModeAvailable = enabled.value && isIcom
val splitModeLabelColor = if (isSplitModeAvailable) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
}
// TX Radio (always shown; in split mode this is the single IC-705)
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
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",
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
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))
// RX Radio (hidden in split mode — the same radio handles both)
if (!isSingleRadio) {
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
if (rxAddress.value.isNotBlank()) {
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
}
Text("Radio devices", fontWeight = FontWeight.Medium)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { selectingFor.value = "rx" },
text = "Select RX Device",
modifier = Modifier.fillMaxWidth()
onClick = { selectingFor.value = "tx" },
text = if (isSingleRadio) "Select Radio" else "Select TX",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
text = if (isSingleRadio) "RX = TX" else "Select RX",
modifier = Modifier.weight(1f)
)
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()) {
Text(
text = "Paired Bluetooth Devices:",
text = stringResource(R.string.settings_paired_devices),
fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
if (pairedDevices.isEmpty()) {
if (pairedBluetoothDevices.isEmpty()) {
Text(
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
fontSize = 13.sp
)
} else {
pairedDevices.forEach { (name, address) ->
pairedBluetoothDevices.forEach { (name, address) ->
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectingFor.value == "tx") {
txAddress.value = address
txName.value = name
txName.value = name
if (isSingleRadio) {
rxAddress.value = address
rxName.value = name
}
} else {
rxAddress.value = address
rxName.value = name
rxName.value = name
}
selectingFor.value = ""
}
.padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(name, modifier = Modifier.weight(1f))
@@ -658,15 +876,26 @@ fun RadioControlDialog(
Spacer(modifier = Modifier.height(6.dp))
}
// Baud rate — FlowRow so all chips fit on narrow screens
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 ->
FilterChip(
selected = rate == baudRate.intValue,
onClick = { baudRate.intValue = rate },
label = { Text(rate.toString(), fontSize = 12.sp) },
enabled = enabled.value
onClick = { baudRate.intValue = rate },
label = {
Text(
text = baudLabel(rate),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
)
}
}
@@ -674,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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -61,6 +64,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -71,6 +75,7 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
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.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
@@ -89,22 +94,10 @@ fun SettingsDestination() {
@Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val dialogs = rememberDialogVisibility()
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
val permissions = rememberSettingsPermissions(
sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true },
onNetworkGranted = { dialogs.network = true },
onCustomSourcesPermissionGranted = {
pendingCustomSourcesGrant.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
},
onCustomSourcesPermissionDenied = {
pendingCustomSourcesDeny.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
}
onNetworkGranted = { dialogs.network = true }
)
// Dialogs
@@ -125,30 +118,23 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
if (dialogs.dataSources) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
requestCustomSourcesPermission = { onGranted, onDenied ->
pendingCustomSourcesGrant.value = onGranted
pendingCustomSourcesDeny.value = onDenied
permissions.launchCustomSourcesPermission()
},
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
statusCodes = uiState.dataSourcesStatus,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val current = uiState.dataSourcesSettings
val newSettings = current.copy(
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
val newSettings = DataSourcesSettings(
satelliteUrls = satUrls,
transceiversUrls = txUrls,
satelliteEnabled = satEnabled,
transceiversEnabled = txEnabled
)
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
onAction(SettingsAction.UpdateFromWeb)
}
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb)
}
)
}
@@ -156,14 +142,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
NetworkOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt
frequencyPort = freqPort, frequencyFormat = freqFmt,
frequencyOffsetHz = freqOffsetHz
)
)
)
@@ -190,14 +177,18 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (dialogs.radioControl) {
RadioControlDialog(
initialSettings = uiState.radioControlSettings,
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.whatsNew) {
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
}
// URLs for top bar
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 fdroidTitle = stringResource(R.string.prefs_fdroid_title)
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
@@ -214,7 +205,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (isVerticalLayout) {
TopBar {
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
@@ -242,7 +233,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
@@ -298,8 +289,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
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())
}
}
}
}
}
}
@@ -337,9 +342,9 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°")
Text(text = "Qth: ${settings.stationPos.qthLocator}")
Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude))
Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude))
Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator))
}
Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -478,40 +483,83 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values) {}
OtherCard(settings = values, onAction = {})
}
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
) {
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
)
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
Spacer(modifier = Modifier.height(4.dp))
// 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) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
Spacer(modifier = Modifier.height(4.dp))
// Compass calibration sliders at the bottom
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset,
value = settings.radarCompassOffset,
range = -180f..180f
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
Spacer(modifier = Modifier.height(4.dp))
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset_elev,
value = settings.radarCompassOffsetElev,
range = -90f..90f
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
Spacer(modifier = Modifier.height(4.dp))
}
}
}
@Composable
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
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
@@ -524,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
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -558,7 +621,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.height(268.dp)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
@@ -592,7 +654,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_satellites),
painter = painterResource(id = R.drawable.ic_sputnik),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
@@ -643,6 +705,7 @@ private class DialogVisibility {
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
var whatsNew by mutableStateOf(false)
}
@Composable
@@ -650,12 +713,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
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 = {
DialogVisibility().apply {
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]
}
}
)
@@ -672,17 +735,14 @@ private class SettingsPermissions(
val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit,
val launchNetwork: () -> Unit,
val launchCustomSourcesPermission: () -> Unit
val launchNetwork: () -> Unit
)
@Composable
private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit,
onNetworkGranted: () -> Unit,
onCustomSourcesPermissionGranted: () -> Unit,
onCustomSourcesPermissionDenied: () -> Unit
onNetworkGranted: () -> Unit
): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult(
@@ -719,15 +779,6 @@ private fun rememberSettingsPermissions(
else sendAction(SettingsAction.ShowToast(networkError))
}
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
onCustomSourcesPermissionGranted()
} else {
onCustomSourcesPermissionDenied()
sendAction(SettingsAction.ShowToast(networkError))
}
}
return remember {
SettingsPermissions(
launchLocation = {
@@ -744,13 +795,6 @@ private fun rememberSettingsPermissions(
} else {
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 rcSettings: RCSettings,
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 {
@@ -64,6 +66,8 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(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
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SettingsViewModel(
private val container: IMainContainer,
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast
@@ -47,7 +48,9 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.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) }
}
}
viewModelScope.launch {
settingsRepo.dataSourcesStatus.collect { status ->
_uiState.update { it.copy(dataSourcesStatus = status) }
}
}
viewModelScope.launch {
settingsRepo.radioControlSettings.collect { 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.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = 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
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
@@ -196,7 +206,6 @@ class SettingsViewModel(
}
}
// endregion
companion object {
@@ -204,6 +213,7 @@ class SettingsViewModel(
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SettingsViewModel(
container = container,
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast()
+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.status"
}
@@ -0,0 +1,622 @@
package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp
private data class UploadOption(
val apiValue: String,
val labelResId: Int,
val color: Color
)
private val UPLOAD_OPTIONS = listOf(
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
)
/**
* Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
*/
@Composable
private fun statusColorOf(statusText: String): Color {
return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiary
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer
statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F)
else ->
MaterialTheme.colorScheme.error
}
}
/** Localize AMSAT API status text for the current locale; falls back to the raw text. */
@Composable
private fun statusTextOf(statusText: String): String {
if (Locale.getDefault().language != Locale.CHINESE.language) return statusText
return when (statusText.lowercase()) {
"heard" -> stringResource(R.string.amsat_status_heard)
"telemetry only" -> stringResource(R.string.amsat_status_tlm_only)
"not heard" -> stringResource(R.string.amsat_status_not_heard)
"crew active" -> stringResource(R.string.amsat_status_crew_active)
else -> statusText
}
}
@Composable
fun SatStatusDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SatStatusScreen(
uiState = uiState,
refresh = viewModel::refresh,
onToggleUpload = viewModel::toggleUploadPanel,
onUploadReportChange = viewModel::setUploadReport,
onUploadCallsignChange = viewModel::setUploadCallsign,
onUploadGridChange = viewModel::setUploadGrid,
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
onDismissUpload = viewModel::collapseUploadPanel
)
}
@Composable
private fun SatStatusScreen(
uiState: SatStatusUiState,
refresh: () -> Unit,
onToggleUpload: () -> Unit,
onUploadReportChange: (String) -> Unit,
onUploadCallsignChange: (String) -> Unit,
onUploadGridChange: (String) -> Unit,
onRequestSubmitUpload: () -> Unit,
onConfirmSubmitUpload: (String) -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismissUpload: () -> Unit
) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
LegendRow()
when {
uiState.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.error != null && uiState.statuses.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
}
}
else -> {
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
}
}
}
}
}
selectedDay?.let { (status, day) ->
ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
upload = uiState.upload,
onToggleUpload = onToggleUpload,
onReportChange = onUploadReportChange,
onCallsignChange = onUploadCallsignChange,
onGridChange = onUploadGridChange,
onRequestSubmitReport = onRequestSubmitUpload,
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
onDismiss = {
selectedDay = null
onDismissUpload()
}
)
}
}
/** Top: update time + refresh button (spinner while loading) */
@Composable
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (fetchedAtUtcMs > 0)
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
else
stringResource(id = R.string.amsat_title),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh),
contentAlignment = Alignment.Center
) {
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Image(
painter = painterResource(id = R.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier.size(16.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
}
}
}
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
@Composable
private fun LegendRow() {
val legend = listOf(
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
legend.forEach { (label, color) ->
val alphaColor = color.copy(alpha = 0.25f)
Row(
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(alphaColor)
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
@Composable
private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.amsat_name),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 4.dp)
)
dates.forEach { date ->
Text(
text = date,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(TILE_WIDTH)
)
}
}
}
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = status.name,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 4.dp)
)
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell(
slot = slot,
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) }
)
}
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none. */
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
Box(
modifier = modifier
.height(28.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
/** Report detail dialog (callsign / date / time / grid) + AMSAT report upload panel. */
@Composable
private fun ReportDialog(
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
upload: AmSatUploadUiState,
onToggleUpload: () -> Unit,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit,
onConfirmSubmitReport: () -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog(
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
onAccept = onDismiss,
extraAction = {
CardButton(
onClick = onToggleUpload,
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
)
}
) {
AnimatedVisibility(visible = upload.isExpanded) {
AmSatUploadPanel(
statusName = statusName,
upload = upload,
onReportChange = onReportChange,
onCallsignChange = onCallsignChange,
onGridChange = onGridChange,
onRequestSubmitReport = onRequestSubmitReport
)
}
if (upload.isConfirmingSubmit) {
AmSatUploadConfirmDialog(
statusName = statusName,
upload = upload,
onConfirm = onConfirmSubmitReport,
onDismiss = onDismissSubmitConfirmation
)
}
if (dayReports.isEmpty()) {
Text(
text = stringResource(id = R.string.amsat_no_reports),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
items(dayReports) { report ->
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColorOf(report.statusText))
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = statusTextOf(report.statusText), fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(thickness = 0.5.dp)
}
}
}
}
}
@Composable
private fun AmSatUploadPanel(
statusName: String,
upload: AmSatUploadUiState,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit
) {
val options = remember(statusName) {
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.amsat_upload_report),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.amsat_upload_status),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
options.forEach { option ->
val selected = upload.selectedReport == option.apiValue
FilterChip(
selected = selected,
onClick = { onReportChange(option.apiValue) },
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (selected) Color.White else option.color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(option.labelResId), maxLines = 1)
}
},
colors = FilterChipDefaults.filterChipColors(
containerColor = option.color.copy(alpha = 0.10f),
labelColor = MaterialTheme.colorScheme.onSurface,
selectedContainerColor = option.color,
selectedLabelColor = Color.White
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selected,
borderColor = option.color.copy(alpha = 0.60f),
selectedBorderColor = option.color
)
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = upload.callsign,
onValueChange = onCallsignChange,
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = upload.gridSquare,
onValueChange = onGridChange,
label = { Text(stringResource(R.string.amsat_upload_grid)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Button(
onClick = onRequestSubmitReport,
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (upload.isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(R.string.amsat_upload_submit))
}
}
upload.message?.let { message ->
if (message == UPLOAD_MESSAGE_SUCCESS) {
Text(
text = stringResource(R.string.amsat_upload_success),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
upload.error?.let { error ->
Text(
text = uploadErrorText(error),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun AmSatUploadConfirmDialog(
statusName: String,
upload: AmSatUploadUiState,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
val statusLabel = stringResource(option.labelResId)
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
text = {
Text(
text = stringResource(
R.string.amsat_upload_confirm_message,
statusName,
statusLabel,
upload.callsign,
grid
)
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.amsat_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
@Composable
private fun uploadErrorText(error: String): String {
return when (error) {
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
else -> stringResource(R.string.amsat_upload_failed, error)
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance()
cal.timeInMillis = utcMs
val day = cal.get(Calendar.DAY_OF_MONTH)
val monthIndex = cal.get(Calendar.MONTH)
val year = cal.get(Calendar.YEAR)
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
} else {
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
}
}
private fun formatUtcNowForUpload(utcMs: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return formatter.format(Date(utcMs))
}
@@ -0,0 +1,291 @@
package com.rtbishop.look4sat.feature.status
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Locale
const val AMSAT_REPORT_HEARD = "Heard"
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
const val UPLOAD_MESSAGE_SUCCESS = "success"
data class AmSatUploadUiState(
val isExpanded: Boolean = false,
val selectedReport: String = AMSAT_REPORT_HEARD,
val callsign: String = "",
val gridSquare: String = "",
val isSubmitting: Boolean = false,
val isConfirmingSubmit: Boolean = false,
val message: String? = null,
val error: String? = null
)
data class SatStatusUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null,
val upload: AmSatUploadUiState = AmSatUploadUiState()
)
class SatStatusViewModel(
private val amSatRepo: IAmSatRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
fetch()
}
fun fetch() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
applyStatusPage(page)
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
}
}
} catch (exception: Exception) {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
}
}
}
}
fun refresh() {
if (_uiState.value.isRefreshing) return
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
applyStatusPage(page)
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
} catch (exception: Exception) {
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
}
}
}
private fun applyStatusPage(page: SatStatusPage) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs,
error = null
)
}
}
fun toggleUploadPanel() {
val storedCallsign = settingsRepo.getAmSatCallsign()
val defaultGrid = defaultGridSquare()
_uiState.update { state ->
val upload = state.upload
val expanded = !upload.isExpanded
state.copy(
upload = if (expanded) {
upload.copy(
isExpanded = true,
callsign = upload.callsign.ifBlank { storedCallsign },
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
message = null,
error = null
)
} else {
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
}
)
}
}
fun collapseUploadPanel() {
_uiState.update {
it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadReport(report: String) {
_uiState.update {
it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadCallsign(callsign: String) {
val normalized = callsign.trim().uppercase(Locale.US)
settingsRepo.setAmSatCallsign(normalized)
_uiState.update {
it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadGrid(grid: String) {
val normalized = grid.trim().uppercase(Locale.US)
_uiState.update {
it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null))
}
}
fun requestSubmitConfirmation() {
val prepared = validateUpload(_uiState.value.upload) ?: return
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isConfirmingSubmit = true,
message = null,
error = null
)
)
}
}
fun dismissSubmitConfirmation() {
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
}
fun confirmSubmitReport(satelliteName: String) {
val upload = _uiState.value.upload
if (upload.isSubmitting) return
val prepared = validateUpload(upload) ?: return
settingsRepo.setAmSatCallsign(prepared.callsign)
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isSubmitting = true,
isConfirmingSubmit = false,
error = null,
message = null
)
)
}
viewModelScope.launch {
val result = amSatRepo.submitReport(
AmSatReportSubmission(
name = satelliteName,
report = prepared.report,
callsign = prepared.callsign,
gridSquare = prepared.grid,
reportedAtUtcMillis = System.currentTimeMillis()
)
)
if (result.success) {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
callsign = prepared.callsign,
gridSquare = prepared.grid,
message = UPLOAD_MESSAGE_SUCCESS,
error = null
)
)
}
refresh()
} else {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
message = null,
error = result.message.ifBlank { "Upload failed" }
)
)
}
}
}
}
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
val callsign = upload.callsign.trim().uppercase(Locale.US)
val grid = upload.gridSquare.trim().uppercase(Locale.US)
return when {
callsign.isBlank() -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
message = null
)
)
}
null
}
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_GRID_INVALID,
message = null
)
)
}
null
}
else -> PreparedUpload(
report = upload.selectedReport,
callsign = callsign,
grid = grid
)
}
}
private fun defaultGridSquare(): String {
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
}
}
}
}
private data class PreparedUpload(
val report: String,
val callsign: String,
val grid: String
)
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
+1
View File
@@ -2,4 +2,5 @@
org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.tooling.parallel=true
org.gradle.workers.max=4
+13 -9
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "443"
appVersionCode = "447"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.3"
appVersionName = "4.4.7"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -12,24 +12,25 @@ jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.3.1"
android-gradle-plugin = "9.4.0"
androidx-core-ktx = "1.19.0"
androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4"
androidx-room = "2.8.5"
compose-bom = "2026.06.01"
compose-bom = "2026.09.00"
compose-activity = "1.13.0"
compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.5"
compose-navigation3 = "1.1.7"
google-ksp = "2.3.8"
google-ksp = "2.3.11"
kotlin = "2.4.10"
kotlin = "2.4.20"
kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0"
other-okhttp = "5.4.0"
other-detekt = "1.23.8"
other-okhttp = "5.5.0"
other-osmdroid = "6.1.20"
test-junit4 = "4.13.2"
@@ -72,6 +73,8 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve
#noinspection UnusedVersionCatalogEntry
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
#noinspection UnusedVersionCatalogEntry
other-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "other-detekt" }
#noinspection UnusedVersionCatalogEntry
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
#noinspection UnusedVersionCatalogEntry
@@ -90,6 +93,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
other-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "other-detekt" }
# plugins defined by this project
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
+2 -2
View File
@@ -1,7 +1,7 @@
#Sat Jun 27 12:51:12 BST 2026
#Sat Sep 12 11:29:59 BST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+2 -1
View File
@@ -25,5 +25,6 @@ include(
":feature:passes",
":feature:radar",
":feature:satellites",
":feature:settings"
":feature:settings",
":feature:status"
)