mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1277763de | ||
|
|
d471f02370 | ||
|
|
9699d142fa | ||
|
|
6f3e3351c9 | ||
|
|
d4a2fae0cd | ||
|
|
d0bbb97f29 | ||
|
|
c2fc5688b9 | ||
|
|
796056a582 | ||
|
|
9cac33dae7 | ||
|
|
082266e385 | ||
|
|
a42a5f1f0d | ||
|
|
7cdc2952dc | ||
|
|
bd51044690 | ||
|
|
1982c2d3dc | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
8b5960282f | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "never"
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -64,3 +64,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
.hermes/
|
||||||
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
no tracking, no network required after initial data download.
|
||||||
data download.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture & Design
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
**Clean Architecture layers:**
|
||||||
@@ -34,9 +33,11 @@ data download.
|
|||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
| `feature:settings` | User preferences |
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
**Feature isolation:**
|
||||||
|
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||||
|
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||||
|
|
||||||
## Build & Run
|
## Build & Platform
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Debug build
|
# Debug build
|
||||||
@@ -50,54 +51,55 @@ data download.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||||
|
|
||||||
## Key Libraries
|
## Tech Stack
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||||
- **OkHttp** 5.x for TLE downloads
|
- **OkHttp** 5.x for data downloads
|
||||||
- **OSMDroid** for map rendering
|
- **OSMDroid** for map rendering
|
||||||
- **Kotlin Serialization** for navigation args and data parsing
|
- **Kotlin Serialization** for navigation args and parsing
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
|
||||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
|
||||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
|
||||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
|
||||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
|
||||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
|
||||||
|
|
||||||
## Data Formats & Migration
|
## Data Formats & Migration
|
||||||
|
|
||||||
**TLE vs. OMM/CSV format:**
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||||
|
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||||
|
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||||
|
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||||
|
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||||
|
- Existing code already supports transparent source transition without feature changes
|
||||||
|
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||||
|
|
||||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
## Engineering Heuristics (Lazy = Efficient)
|
||||||
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
|
||||||
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
|
||||||
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
|
||||||
|
|
||||||
**Current implementation:**
|
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||||
- TLE data is downloaded from configured sources and stored in Room database
|
1. Does this need to be built now? (YAGNI)
|
||||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
3. Does Kotlin/Java stdlib already solve it?
|
||||||
|
4. Does the Android/platform API already solve it?
|
||||||
|
5. Does an already-installed dependency solve it?
|
||||||
|
6. Can this be simpler (including one-liner simple)?
|
||||||
|
7. Only then: write the minimum code that works.
|
||||||
|
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||||
|
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||||
|
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||||
|
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||||
|
- When two options are similar in size, choose the edge-case-correct one.
|
||||||
|
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||||
|
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||||
|
|
||||||
**Migration path:**
|
## Bug-Fix Policy
|
||||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
|
||||||
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
|
||||||
transition to OMM/CSV automatically.
|
|
||||||
|
|
||||||
## Code Style
|
- Fix root cause, not just the reported symptom.
|
||||||
|
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
- The smallest correct diff wins only after behavior is understood.
|
||||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
|
||||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
|
||||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
|
||||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
|
||||||
|
|
||||||
## Copilot Working Mode: Code-Only
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ It is now and always will be completely ad-free and open-source.
|
|||||||
|
|
||||||
## Star History
|
## 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>
|
<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: 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://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?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>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
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.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
@@ -70,6 +68,8 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
@@ -82,178 +82,196 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
|
|||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavRoot(deeplink: String? = null) {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val deeplinkResolver = DeeplinkResolver()
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
LaunchedEffect(deeplink) {
|
LaunchedEffect(deeplink) {
|
||||||
deeplink?.let {
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
|
||||||
rootBackStack.add(destination)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||||
NavDisplay(
|
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||||
modifier = Modifier.fillMaxSize(),
|
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||||
backStack = rootBackStack,
|
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||||
onBack = navigateBack,
|
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||||
transitionSpec = { slideInTransition },
|
slideOutHorizontally(tween(300)) { it }
|
||||||
popTransitionSpec = { slideOutTransition },
|
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||||
predictivePopTransitionSpec = { slideOutTransition },
|
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||||
entryDecorators = listOf(
|
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
|
||||||
entry<RadarDestination> {
|
|
||||||
Scaffold { innerPadding ->
|
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
innerPadding.calculateTopPadding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
|
||||||
val 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)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
low = otherSettings.lowElevation,
|
low = otherSettings.lowElevation,
|
||||||
high = otherSettings.highElevation
|
high = otherSettings.highElevation
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
NavigationSuiteScaffold(
|
NavDisplay(
|
||||||
navigationSuiteItems = {
|
modifier = Modifier.fillMaxSize(),
|
||||||
navItems.forEach { screen ->
|
backStack = rootBackStack,
|
||||||
val isSelected = when (currentKey) {
|
onBack = navigateBack,
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
transitionSpec = { pushTransition },
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
popTransitionSpec = { popTransition },
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
predictivePopTransitionSpec = { popTransition },
|
||||||
is Screen.Map -> screen is Screen.Map
|
entryDecorators = listOf(
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
else -> false
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
}
|
|
||||||
item(
|
|
||||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = {
|
|
||||||
if (isSelected) return@item
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
),
|
),
|
||||||
layoutType = when {
|
entryProvider = entryProvider {
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
entry<RadarDestination> {
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
Column {
|
}
|
||||||
NavDisplay(
|
}
|
||||||
backStack = backStack,
|
|
||||||
modifier = Modifier.weight(1f),
|
@Composable
|
||||||
onBack = navigateBack,
|
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
transitionSpec = { fadeTransition },
|
val context = LocalContext.current
|
||||||
popTransitionSpec = { fadeTransition },
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
entryDecorators = listOf(
|
|
||||||
// Required for saving Compose state per entry
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
val currentKey = backStack.lastOrNull()
|
||||||
// Required for ViewModel scoping per entry
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
||||||
),
|
fadeOut(animationSpec = tween(300))
|
||||||
entryProvider = entryProvider {
|
val navItems =
|
||||||
entry<Screen.Satellites> {
|
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
|
||||||
|
NavigationSuiteScaffold(
|
||||||
|
navigationSuiteItems = {
|
||||||
|
navItems.forEach { screen ->
|
||||||
|
val isSelected = when (currentKey) {
|
||||||
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
|
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(
|
||||||
|
painter = painterResource(screen.iconResId),
|
||||||
|
contentDescription = stringResource(screen.titleResId)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected) return@item
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
),
|
||||||
|
layoutType = when {
|
||||||
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
NavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { fadeTransition },
|
||||||
|
popTransitionSpec = { fadeTransition },
|
||||||
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
}
|
||||||
PassesDestination { catNum, aosTime ->
|
entry<Screen.Status> { SatStatusDestination() }
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
entry<Screen.Map> { MapDestination() }
|
||||||
backStack.add(Screen.Radar)
|
entry<Screen.Settings> { SettingsDestination() }
|
||||||
// navigateToRadar()
|
}
|
||||||
}
|
)
|
||||||
}
|
if (trackingState.isActive) {
|
||||||
entry<Screen.Radar> {
|
TrackingBanner(
|
||||||
RadarDestination(navigateUp = navigateBack)
|
state = trackingState,
|
||||||
}
|
onClick = {
|
||||||
entry<Screen.Map> {
|
val pass = trackingState.currentPass
|
||||||
MapDestination()
|
if (pass != null) {
|
||||||
}
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
entry<Screen.Settings> {
|
navigateToRadar()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
|
implementation(project(":feature:status"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
implementation(libs.compose.navigation3)
|
implementation(libs.compose.navigation3)
|
||||||
|
|||||||
@@ -5,6 +5,46 @@ plugins {
|
|||||||
alias(libs.plugins.google.ksp) apply false
|
alias(libs.plugins.google.ksp) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.other.detekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
|
||||||
|
description = "Checks that source code satisfies detekt rules."
|
||||||
|
autoCorrect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
|
||||||
|
description = "Applies code formatting rules to source code in-place."
|
||||||
|
autoCorrect = true
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(listOf(detektCheck, detektApply)) {
|
||||||
|
configure {
|
||||||
|
group = "verification"
|
||||||
|
parallel = true
|
||||||
|
ignoreFailures = false
|
||||||
|
setSource(file(rootDir))
|
||||||
|
|
||||||
|
// Custom detekt config
|
||||||
|
config.setFrom("$projectDir/config/detekt/detekt.yml")
|
||||||
|
|
||||||
|
// Use default configuration on top of custom config
|
||||||
|
// (new detect rules will work out of the box after upgrading detekt version)
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
|
||||||
|
// Runs detekt for all files in the Gradle project and all subprojects without
|
||||||
|
// a need to configure detekt plugin in every subproject.
|
||||||
|
include("**/*.kt", "**/*.kts")
|
||||||
|
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
|
||||||
|
|
||||||
|
reports {
|
||||||
|
html.required.set(true)
|
||||||
|
xml.required.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
detektPlugins(libs.other.detekt.formatting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
build:
|
||||||
|
maxIssues: 0
|
||||||
|
excludeCorrectable: false
|
||||||
|
weights:
|
||||||
|
# complexity: 2
|
||||||
|
# LongParameterList: 1
|
||||||
|
# style: 1
|
||||||
|
# comments: 1
|
||||||
|
|
||||||
|
config:
|
||||||
|
validation: true
|
||||||
|
warningsAsErrors: false
|
||||||
|
checkExhaustiveness: false
|
||||||
|
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
|
||||||
|
excludes: ''
|
||||||
|
|
||||||
|
processors:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'DetektProgressListener'
|
||||||
|
# - 'KtFileCountProcessor'
|
||||||
|
# - 'PackageCountProcessor'
|
||||||
|
# - 'ClassCountProcessor'
|
||||||
|
# - 'FunctionCountProcessor'
|
||||||
|
# - 'PropertyCountProcessor'
|
||||||
|
# - 'ProjectComplexityProcessor'
|
||||||
|
# - 'ProjectCognitiveComplexityProcessor'
|
||||||
|
# - 'ProjectLLOCProcessor'
|
||||||
|
# - 'ProjectCLOCProcessor'
|
||||||
|
# - 'ProjectLOCProcessor'
|
||||||
|
# - 'ProjectSLOCProcessor'
|
||||||
|
# - 'LicenseHeaderLoaderExtension'
|
||||||
|
|
||||||
|
console-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'ProjectStatisticsReport'
|
||||||
|
- 'ComplexityReport'
|
||||||
|
- 'NotificationReport'
|
||||||
|
- 'FindingsReport'
|
||||||
|
- 'FileBasedFindingsReport'
|
||||||
|
# - 'LiteFindingsReport'
|
||||||
|
|
||||||
|
output-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
# - 'TxtOutputReport'
|
||||||
|
# - 'XmlOutputReport'
|
||||||
|
- 'HtmlOutputReport'
|
||||||
|
# - 'MdOutputReport'
|
||||||
|
# - 'SarifOutputReport'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
active: true
|
||||||
|
AbsentOrWrongFileLicense:
|
||||||
|
active: false
|
||||||
|
licenseTemplateFile: 'license.template'
|
||||||
|
licenseTemplateIsRegex: false
|
||||||
|
CommentOverPrivateFunction:
|
||||||
|
active: false
|
||||||
|
CommentOverPrivateProperty:
|
||||||
|
active: false
|
||||||
|
DeprecatedBlockTag:
|
||||||
|
active: false
|
||||||
|
EndOfSentenceFormat:
|
||||||
|
active: false
|
||||||
|
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
|
||||||
|
KDocReferencesNonPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
OutdatedDocumentation:
|
||||||
|
active: false
|
||||||
|
matchTypeParameters: true
|
||||||
|
matchDeclarationsOrder: true
|
||||||
|
allowParamOnConstructorProperties: false
|
||||||
|
UndocumentedPublicClass:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchInNestedClass: true
|
||||||
|
searchInInnerClass: true
|
||||||
|
searchInInnerObject: true
|
||||||
|
searchInInnerInterface: true
|
||||||
|
searchInProtectedClass: false
|
||||||
|
UndocumentedPublicFunction:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedFunction: false
|
||||||
|
UndocumentedPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedProperty: false
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
active: true
|
||||||
|
CognitiveComplexMethod:
|
||||||
|
active: false
|
||||||
|
threshold: 15
|
||||||
|
ComplexCondition:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
ComplexInterface:
|
||||||
|
active: false
|
||||||
|
threshold: 10
|
||||||
|
includeStaticDeclarations: false
|
||||||
|
includePrivateDeclarations: false
|
||||||
|
ignoreOverloaded: false
|
||||||
|
CyclomaticComplexMethod:
|
||||||
|
active: true
|
||||||
|
threshold: 15
|
||||||
|
ignoreSingleWhenExpression: false
|
||||||
|
ignoreSimpleWhenEntries: false
|
||||||
|
ignoreNestingFunctions: false
|
||||||
|
nestingFunctions:
|
||||||
|
- 'also'
|
||||||
|
- 'apply'
|
||||||
|
- 'forEach'
|
||||||
|
- 'isNotNull'
|
||||||
|
- 'ifNull'
|
||||||
|
- 'let'
|
||||||
|
- 'run'
|
||||||
|
- 'use'
|
||||||
|
- 'with'
|
||||||
|
# Composables describe UI trees declaratively and naturally accumulate more branches than
|
||||||
|
# regular functions. Exclude them here and rely on code review + decomposition instead.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
LabeledExpression:
|
||||||
|
active: false
|
||||||
|
ignoredLabels: [ ]
|
||||||
|
LargeClass:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 600
|
||||||
|
LongMethod:
|
||||||
|
active: true
|
||||||
|
# Composable UI functions are declarative trees; 60 lines is too strict for them.
|
||||||
|
# Screen/Slots files are excluded — keep them reasonable via code review.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 60
|
||||||
|
LongParameterList:
|
||||||
|
active: false
|
||||||
|
functionThreshold: 6
|
||||||
|
constructorThreshold: 7
|
||||||
|
ignoreDefaultParameters: false
|
||||||
|
ignoreDataClasses: true
|
||||||
|
ignoreAnnotatedParameter: [ ]
|
||||||
|
MethodOverloading:
|
||||||
|
active: false
|
||||||
|
threshold: 6
|
||||||
|
NamedArguments:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ignoreArgumentsMatchingNames: false
|
||||||
|
NestedBlockDepth:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
NestedScopeFunctions:
|
||||||
|
active: false
|
||||||
|
threshold: 1
|
||||||
|
functions:
|
||||||
|
- 'kotlin.apply'
|
||||||
|
- 'kotlin.run'
|
||||||
|
- 'kotlin.with'
|
||||||
|
- 'kotlin.let'
|
||||||
|
- 'kotlin.also'
|
||||||
|
ReplaceSafeCallChainWithRun:
|
||||||
|
active: false
|
||||||
|
StringLiteralDuplication:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
threshold: 3
|
||||||
|
ignoreAnnotation: true
|
||||||
|
excludeStringsWithLessThan5Characters: true
|
||||||
|
ignoreStringsRegex: '$^'
|
||||||
|
TooManyFunctions:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
thresholdInFiles: 11
|
||||||
|
thresholdInClasses: 11
|
||||||
|
thresholdInInterfaces: 11
|
||||||
|
thresholdInObjects: 11
|
||||||
|
thresholdInEnums: 11
|
||||||
|
ignoreDeprecated: false
|
||||||
|
ignorePrivate: false
|
||||||
|
ignoreOverridden: false
|
||||||
|
|
||||||
|
coroutines:
|
||||||
|
active: true
|
||||||
|
GlobalCoroutineUsage:
|
||||||
|
active: false
|
||||||
|
InjectDispatcher:
|
||||||
|
active: true
|
||||||
|
dispatcherNames:
|
||||||
|
- 'IO'
|
||||||
|
- 'Default'
|
||||||
|
- 'Unconfined'
|
||||||
|
RedundantSuspendModifier:
|
||||||
|
active: true
|
||||||
|
SleepInsteadOfDelay:
|
||||||
|
active: true
|
||||||
|
SuspendFunSwallowedCancellation:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithCoroutineScopeReceiver:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithFlowReturnType:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
empty-blocks:
|
||||||
|
active: true
|
||||||
|
EmptyCatchBlock:
|
||||||
|
active: true
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
EmptyClassBlock:
|
||||||
|
active: true
|
||||||
|
EmptyDefaultConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyDoWhileBlock:
|
||||||
|
active: true
|
||||||
|
EmptyElseBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFinallyBlock:
|
||||||
|
active: true
|
||||||
|
EmptyForBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFunctionBlock:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
EmptyIfBlock:
|
||||||
|
active: true
|
||||||
|
EmptyInitBlock:
|
||||||
|
active: true
|
||||||
|
EmptyKtFile:
|
||||||
|
active: true
|
||||||
|
EmptySecondaryConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyTryBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhenBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhileBlock:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
active: true
|
||||||
|
ExceptionRaisedInUnexpectedLocation:
|
||||||
|
active: true
|
||||||
|
methodNames:
|
||||||
|
- 'equals'
|
||||||
|
- 'finalize'
|
||||||
|
- 'hashCode'
|
||||||
|
- 'toString'
|
||||||
|
InstanceOfCheckForException:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
NotImplementedDeclaration:
|
||||||
|
active: false
|
||||||
|
ObjectExtendsThrowable:
|
||||||
|
active: false
|
||||||
|
PrintStackTrace:
|
||||||
|
active: true
|
||||||
|
RethrowCaughtException:
|
||||||
|
active: true
|
||||||
|
ReturnFromFinally:
|
||||||
|
active: true
|
||||||
|
ignoreLabeled: false
|
||||||
|
SwallowedException:
|
||||||
|
active: true
|
||||||
|
ignoredExceptionTypes:
|
||||||
|
- 'InterruptedException'
|
||||||
|
- 'MalformedURLException'
|
||||||
|
- 'NumberFormatException'
|
||||||
|
- 'ParseException'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
ThrowingExceptionFromFinally:
|
||||||
|
active: true
|
||||||
|
ThrowingExceptionInMain:
|
||||||
|
active: false
|
||||||
|
ThrowingExceptionsWithoutMessageOrCause:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptions:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalArgumentException'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IllegalStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
ThrowingNewInstanceOfSameException:
|
||||||
|
active: true
|
||||||
|
TooGenericExceptionCaught:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptionNames:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
TooGenericExceptionThrown:
|
||||||
|
active: true
|
||||||
|
exceptionNames:
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
|
||||||
|
formatting:
|
||||||
|
active: true
|
||||||
|
ArgumentListWrapping:
|
||||||
|
active: false
|
||||||
|
ParameterListWrapping:
|
||||||
|
active: false
|
||||||
|
NoWildcardImports:
|
||||||
|
active: false
|
||||||
|
MaximumLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
|
||||||
|
naming:
|
||||||
|
active: true
|
||||||
|
BooleanPropertyNaming:
|
||||||
|
active: false
|
||||||
|
allowedPattern: '^(is|has|are)'
|
||||||
|
ClassNaming:
|
||||||
|
active: true
|
||||||
|
classPattern: '[A-Z][a-zA-Z0-9_]*'
|
||||||
|
ConstructorParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
privateParameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
EnumNaming:
|
||||||
|
active: true
|
||||||
|
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
|
||||||
|
ForbiddenClassName:
|
||||||
|
active: false
|
||||||
|
forbiddenName: [ ]
|
||||||
|
FunctionMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumFunctionNameLength: 30
|
||||||
|
FunctionMinLength:
|
||||||
|
active: false
|
||||||
|
minimumFunctionNameLength: 3
|
||||||
|
FunctionNaming:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
FunctionParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
InvalidPackageDeclaration:
|
||||||
|
active: true
|
||||||
|
rootPackage: ''
|
||||||
|
requireRootInDeclaration: false
|
||||||
|
LambdaParameterNaming:
|
||||||
|
active: false
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*|_'
|
||||||
|
MatchingDeclarationName:
|
||||||
|
active: false
|
||||||
|
mustBeFirst: false
|
||||||
|
MemberNameEqualsClassName:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
NoNameShadowing:
|
||||||
|
active: true
|
||||||
|
NonBooleanPropertyPrefixedWithIs:
|
||||||
|
active: false
|
||||||
|
ObjectPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
PackageNaming:
|
||||||
|
active: true
|
||||||
|
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
||||||
|
TopLevelPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Z][_A-Z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
VariableMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumVariableNameLength: 64
|
||||||
|
VariableMinLength:
|
||||||
|
active: false
|
||||||
|
minimumVariableNameLength: 1
|
||||||
|
VariableNaming:
|
||||||
|
active: true
|
||||||
|
variablePattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
privateVariablePattern: '(_)?[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
|
||||||
|
performance:
|
||||||
|
active: true
|
||||||
|
ArrayPrimitive:
|
||||||
|
active: true
|
||||||
|
CouldBeSequence:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ForEachOnRange:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
SpreadOperator:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnnecessaryPartOfBinaryExpression:
|
||||||
|
active: false
|
||||||
|
UnnecessaryTemporaryInstantiation:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
potential-bugs:
|
||||||
|
active: true
|
||||||
|
AvoidReferentialEquality:
|
||||||
|
active: true
|
||||||
|
forbiddenTypePatterns:
|
||||||
|
- 'kotlin.String'
|
||||||
|
CastNullableToNonNullableType:
|
||||||
|
active: false
|
||||||
|
CastToNullableType:
|
||||||
|
active: false
|
||||||
|
Deprecation:
|
||||||
|
active: false
|
||||||
|
DontDowncastCollectionTypes:
|
||||||
|
active: false
|
||||||
|
DoubleMutabilityForCollection:
|
||||||
|
active: true
|
||||||
|
mutableTypes:
|
||||||
|
- 'kotlin.collections.MutableList'
|
||||||
|
- 'kotlin.collections.MutableMap'
|
||||||
|
- 'kotlin.collections.MutableSet'
|
||||||
|
- 'java.util.ArrayList'
|
||||||
|
- 'java.util.LinkedHashSet'
|
||||||
|
- 'java.util.HashSet'
|
||||||
|
- 'java.util.LinkedHashMap'
|
||||||
|
- 'java.util.HashMap'
|
||||||
|
ElseCaseInsteadOfExhaustiveWhen:
|
||||||
|
active: false
|
||||||
|
ignoredSubjectTypes: [ ]
|
||||||
|
EqualsAlwaysReturnsTrueOrFalse:
|
||||||
|
active: true
|
||||||
|
EqualsWithHashCodeExist:
|
||||||
|
active: true
|
||||||
|
ExitOutsideMain:
|
||||||
|
active: false
|
||||||
|
ExplicitGarbageCollectionCall:
|
||||||
|
active: true
|
||||||
|
HasPlatformType:
|
||||||
|
active: true
|
||||||
|
IgnoredReturnValue:
|
||||||
|
active: true
|
||||||
|
restrictToConfig: true
|
||||||
|
returnValueAnnotations:
|
||||||
|
- 'CheckResult'
|
||||||
|
- '*.CheckResult'
|
||||||
|
- 'CheckReturnValue'
|
||||||
|
- '*.CheckReturnValue'
|
||||||
|
ignoreReturnValueAnnotations:
|
||||||
|
- 'CanIgnoreReturnValue'
|
||||||
|
- '*.CanIgnoreReturnValue'
|
||||||
|
returnValueTypes:
|
||||||
|
- 'kotlin.sequences.Sequence'
|
||||||
|
- 'kotlinx.coroutines.flow.*Flow'
|
||||||
|
- 'java.util.stream.*Stream'
|
||||||
|
ignoreFunctionCall: [ ]
|
||||||
|
ImplicitDefaultLocale:
|
||||||
|
active: true
|
||||||
|
ImplicitUnitReturnType:
|
||||||
|
active: false
|
||||||
|
allowExplicitReturnType: true
|
||||||
|
InvalidRange:
|
||||||
|
active: true
|
||||||
|
IteratorHasNextCallsNextMethod:
|
||||||
|
active: true
|
||||||
|
IteratorNotThrowingNoSuchElementException:
|
||||||
|
active: true
|
||||||
|
LateinitUsage:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
ignoreOnClassesPattern: ''
|
||||||
|
MapGetWithNotNullAssertionOperator:
|
||||||
|
active: true
|
||||||
|
MissingPackageDeclaration:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/*.kts' ]
|
||||||
|
NullCheckOnMutableProperty:
|
||||||
|
active: false
|
||||||
|
NullableToStringCall:
|
||||||
|
active: false
|
||||||
|
PropertyUsedBeforeDeclaration:
|
||||||
|
active: false
|
||||||
|
UnconditionalJumpStatementInLoop:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullCheck:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullOperator:
|
||||||
|
active: true
|
||||||
|
UnnecessarySafeCall:
|
||||||
|
active: true
|
||||||
|
UnreachableCatchBlock:
|
||||||
|
active: true
|
||||||
|
UnreachableCode:
|
||||||
|
active: true
|
||||||
|
UnsafeCallOnNullableType:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnsafeCast:
|
||||||
|
active: true
|
||||||
|
UnusedUnaryOperator:
|
||||||
|
active: true
|
||||||
|
UselessPostfixExpression:
|
||||||
|
active: true
|
||||||
|
WrongEqualsTypeParameter:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
style:
|
||||||
|
active: true
|
||||||
|
AlsoCouldBeApply:
|
||||||
|
active: false
|
||||||
|
BracesOnIfStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'never'
|
||||||
|
multiLine: 'always'
|
||||||
|
BracesOnWhenStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'necessary'
|
||||||
|
multiLine: 'consistent'
|
||||||
|
CanBeNonNullable:
|
||||||
|
active: false
|
||||||
|
CascadingCallWrapping:
|
||||||
|
active: false
|
||||||
|
includeElvis: true
|
||||||
|
ClassOrdering:
|
||||||
|
active: false
|
||||||
|
CollapsibleIfStatements:
|
||||||
|
active: false
|
||||||
|
DataClassContainsFunctions:
|
||||||
|
active: false
|
||||||
|
conversionFunctionPrefix:
|
||||||
|
- 'to'
|
||||||
|
allowOperators: false
|
||||||
|
DataClassShouldBeImmutable:
|
||||||
|
active: false
|
||||||
|
DestructuringDeclarationWithTooManyEntries:
|
||||||
|
active: true
|
||||||
|
maxDestructuringEntries: 3
|
||||||
|
DoubleNegativeLambda:
|
||||||
|
active: false
|
||||||
|
negativeFunctions:
|
||||||
|
- reason: 'Use `takeIf` instead.'
|
||||||
|
value: 'takeUnless'
|
||||||
|
- reason: 'Use `all` instead.'
|
||||||
|
value: 'none'
|
||||||
|
negativeFunctionNameParts:
|
||||||
|
- 'not'
|
||||||
|
- 'non'
|
||||||
|
EqualsNullCall:
|
||||||
|
active: true
|
||||||
|
EqualsOnSignatureLine:
|
||||||
|
active: false
|
||||||
|
ExplicitCollectionElementAccessMethod:
|
||||||
|
active: false
|
||||||
|
ExplicitItLambdaParameter:
|
||||||
|
active: true
|
||||||
|
ExpressionBodySyntax:
|
||||||
|
active: false
|
||||||
|
includeLineWrapping: false
|
||||||
|
ForbiddenAnnotation:
|
||||||
|
active: false
|
||||||
|
annotations:
|
||||||
|
- reason: 'it is a java annotation. Use `Suppress` instead.'
|
||||||
|
value: 'java.lang.SuppressWarnings'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
|
||||||
|
value: 'java.lang.Deprecated'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
|
||||||
|
value: 'java.lang.annotation.Documented'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
|
||||||
|
value: 'java.lang.annotation.Target'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
|
||||||
|
value: 'java.lang.annotation.Retention'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
|
||||||
|
value: 'java.lang.annotation.Repeatable'
|
||||||
|
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
|
||||||
|
value: 'java.lang.annotation.Inherited'
|
||||||
|
ForbiddenComment:
|
||||||
|
active: true
|
||||||
|
comments:
|
||||||
|
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
|
||||||
|
value: 'FIXME:'
|
||||||
|
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
|
||||||
|
value: 'STOPSHIP:'
|
||||||
|
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
|
||||||
|
value: 'TODO:'
|
||||||
|
allowedPatterns: ''
|
||||||
|
ForbiddenImport:
|
||||||
|
active: false
|
||||||
|
imports: [ ]
|
||||||
|
forbiddenPatterns: ''
|
||||||
|
ForbiddenMethodCall:
|
||||||
|
active: false
|
||||||
|
methods:
|
||||||
|
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.print'
|
||||||
|
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.println'
|
||||||
|
ForbiddenSuppress:
|
||||||
|
active: false
|
||||||
|
rules: [ ]
|
||||||
|
ForbiddenVoid:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: false
|
||||||
|
ignoreUsageInGenerics: false
|
||||||
|
FunctionOnlyReturningConstant:
|
||||||
|
active: true
|
||||||
|
ignoreOverridableFunction: true
|
||||||
|
ignoreActualFunction: true
|
||||||
|
excludedFunctions: [ ]
|
||||||
|
LoopWithTooManyJumpStatements:
|
||||||
|
active: true
|
||||||
|
maxJumpCount: 1
|
||||||
|
MagicNumber:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
ignoreNumbers:
|
||||||
|
- '-1'
|
||||||
|
- '0'
|
||||||
|
- '1'
|
||||||
|
- '2'
|
||||||
|
ignoreHashCodeFunction: true
|
||||||
|
ignorePropertyDeclaration: false
|
||||||
|
ignoreLocalVariableDeclaration: false
|
||||||
|
ignoreConstantDeclaration: true
|
||||||
|
ignoreCompanionObjectPropertyDeclaration: true
|
||||||
|
ignoreAnnotation: false
|
||||||
|
ignoreNamedArgument: true
|
||||||
|
ignoreEnums: false
|
||||||
|
ignoreRanges: false
|
||||||
|
ignoreExtensionFunctions: true
|
||||||
|
MandatoryBracesLoops:
|
||||||
|
active: false
|
||||||
|
MaxChainedCallsOnSameLine:
|
||||||
|
active: false
|
||||||
|
maxChainedCalls: 5
|
||||||
|
MaxLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
maxLineLength: 120
|
||||||
|
excludePackageStatements: true
|
||||||
|
excludeImportStatements: true
|
||||||
|
excludeCommentStatements: true
|
||||||
|
excludeRawStrings: true
|
||||||
|
MayBeConst:
|
||||||
|
active: true
|
||||||
|
ModifierOrder:
|
||||||
|
active: true
|
||||||
|
MultilineLambdaItParameter:
|
||||||
|
active: false
|
||||||
|
MultilineRawStringIndentation:
|
||||||
|
active: false
|
||||||
|
indentSize: 4
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
NestedClassesVisibility:
|
||||||
|
active: true
|
||||||
|
NewLineAtEndOfFile:
|
||||||
|
active: true
|
||||||
|
NoTabs:
|
||||||
|
active: false
|
||||||
|
NullableBooleanCheck:
|
||||||
|
active: false
|
||||||
|
ObjectLiteralToLambda:
|
||||||
|
active: true
|
||||||
|
OptionalAbstractKeyword:
|
||||||
|
active: true
|
||||||
|
OptionalUnit:
|
||||||
|
active: false
|
||||||
|
OptionalWhenBraces:
|
||||||
|
active: false
|
||||||
|
PreferToOverPairSyntax:
|
||||||
|
active: false
|
||||||
|
ProtectedMemberInFinalClass:
|
||||||
|
active: true
|
||||||
|
RedundantExplicitType:
|
||||||
|
active: false
|
||||||
|
RedundantHigherOrderMapUsage:
|
||||||
|
active: true
|
||||||
|
RedundantVisibilityModifierRule:
|
||||||
|
active: false
|
||||||
|
ReturnCount:
|
||||||
|
active: true
|
||||||
|
max: 3
|
||||||
|
excludedFunctions:
|
||||||
|
- 'equals'
|
||||||
|
excludeLabeled: false
|
||||||
|
excludeReturnFromLambda: true
|
||||||
|
# Guard clauses (early returns for preconditions) are idiomatic Kotlin and should not
|
||||||
|
# count toward the return limit. E.g. `if (x == null) return null` at function top.
|
||||||
|
excludeGuardClauses: true
|
||||||
|
SafeCast:
|
||||||
|
active: true
|
||||||
|
SerialVersionUIDInSerializableClass:
|
||||||
|
active: true
|
||||||
|
SpacingBetweenPackageAndImports:
|
||||||
|
active: false
|
||||||
|
StringShouldBeRawString:
|
||||||
|
active: false
|
||||||
|
maxEscapedCharacterCount: 2
|
||||||
|
ignoredCharacters: [ ]
|
||||||
|
ThrowsCount:
|
||||||
|
active: true
|
||||||
|
max: 2
|
||||||
|
excludeGuardClauses: false
|
||||||
|
TrailingWhitespace:
|
||||||
|
active: false
|
||||||
|
TrimMultilineRawString:
|
||||||
|
active: false
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
UnderscoresInNumericLiterals:
|
||||||
|
active: false
|
||||||
|
acceptableLength: 4
|
||||||
|
allowNonStandardGrouping: false
|
||||||
|
UnnecessaryAbstractClass:
|
||||||
|
active: true
|
||||||
|
UnnecessaryAnnotationUseSiteTarget:
|
||||||
|
active: false
|
||||||
|
UnnecessaryApply:
|
||||||
|
active: true
|
||||||
|
UnnecessaryBackticks:
|
||||||
|
active: false
|
||||||
|
UnnecessaryBracesAroundTrailingLambda:
|
||||||
|
active: false
|
||||||
|
UnnecessaryFilter:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInheritance:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInnerClass:
|
||||||
|
active: false
|
||||||
|
UnnecessaryLet:
|
||||||
|
active: false
|
||||||
|
UnnecessaryParentheses:
|
||||||
|
active: false
|
||||||
|
allowForUnclearPrecedence: false
|
||||||
|
UntilInsteadOfRangeTo:
|
||||||
|
active: false
|
||||||
|
UnusedImports:
|
||||||
|
active: false
|
||||||
|
UnusedParameter:
|
||||||
|
active: true
|
||||||
|
allowedNames: 'ignored|expected'
|
||||||
|
UnusedPrivateClass:
|
||||||
|
active: true
|
||||||
|
UnusedPrivateMember:
|
||||||
|
active: true
|
||||||
|
allowedNames: '[A-Z][a-zA-Z0-9]*Preview'
|
||||||
|
UnusedPrivateProperty:
|
||||||
|
active: true
|
||||||
|
allowedNames: '_|ignored|expected|serialVersionUID'
|
||||||
|
UseAnyOrNoneInsteadOfFind:
|
||||||
|
active: true
|
||||||
|
UseArrayLiteralsInAnnotations:
|
||||||
|
active: true
|
||||||
|
UseCheckNotNull:
|
||||||
|
active: true
|
||||||
|
UseCheckOrError:
|
||||||
|
active: true
|
||||||
|
UseDataClass:
|
||||||
|
active: false
|
||||||
|
allowVars: false
|
||||||
|
UseEmptyCounterpart:
|
||||||
|
active: false
|
||||||
|
UseIfEmptyOrIfBlank:
|
||||||
|
active: false
|
||||||
|
UseIfInsteadOfWhen:
|
||||||
|
active: false
|
||||||
|
ignoreWhenContainingVariableDeclaration: false
|
||||||
|
UseIsNullOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseLet:
|
||||||
|
active: false
|
||||||
|
UseOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseRequire:
|
||||||
|
active: true
|
||||||
|
UseRequireNotNull:
|
||||||
|
active: true
|
||||||
|
UseSumOfInsteadOfFlatMapSize:
|
||||||
|
active: false
|
||||||
|
UselessCallOnNotNull:
|
||||||
|
active: true
|
||||||
|
UtilityClassWithPublicConstructor:
|
||||||
|
active: true
|
||||||
|
VarCouldBeVal:
|
||||||
|
active: true
|
||||||
|
ignoreLateinitVar: false
|
||||||
|
WildcardImport:
|
||||||
|
active: false
|
||||||
|
excludeImports:
|
||||||
|
- 'java.util.*'
|
||||||
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.MapColumn
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
@@ -39,13 +40,30 @@ interface Look4SatDao {
|
|||||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||||
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
||||||
|
|
||||||
|
@Query("SELECT catnum, epoch FROM entries")
|
||||||
|
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
|
||||||
|
|
||||||
|
@Query("SELECT catnum, name FROM entries")
|
||||||
|
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
|
||||||
|
|
||||||
|
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
|
||||||
|
suspend fun renameEntry(catnum: Int, name: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
|
||||||
|
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertEntries(entries: List<SatEntry>)
|
suspend fun insertEntries(entries: List<SatEntry>)
|
||||||
|
|
||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||||
|
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||||
|
"""
|
||||||
|
)
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
@@ -60,4 +78,7 @@ interface Look4SatDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM radios")
|
@Query("DELETE FROM radios")
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
@@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
|
||||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
const val DATABASE_NAME = "Look4SatDBv400"
|
||||||
|
|
||||||
|
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||||
abstract class Look4SatDb : RoomDatabase() {
|
abstract class Look4SatDb : RoomDatabase() {
|
||||||
abstract fun look4SatDao(): Look4SatDao
|
abstract fun look4SatDao(): Look4SatDao
|
||||||
}
|
}
|
||||||
|
|
||||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||||
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
// database.execSQL("DROP TABLE entries")
|
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||||
// }
|
}
|
||||||
//}
|
}
|
||||||
@@ -30,5 +30,6 @@ data class SatEntry(
|
|||||||
val argper: Double,
|
val argper: Double,
|
||||||
val meanan: Double,
|
val meanan: Double,
|
||||||
val catnum: Int,
|
val catnum: Int,
|
||||||
val bstar: Double
|
val bstar: Double,
|
||||||
|
val ndot: Double = 0.0
|
||||||
)
|
)
|
||||||
@@ -32,5 +32,7 @@ data class SatRadio(
|
|||||||
val uplinkHigh: Long?,
|
val uplinkHigh: Long?,
|
||||||
val uplinkMode: String?,
|
val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
val isInverted: Boolean,
|
||||||
val catnum: Int?
|
val catnum: Int?,
|
||||||
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
+65
-26
@@ -17,8 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -31,18 +33,30 @@ class NetworkReporter(
|
|||||||
private val rotatorServer: String,
|
private val rotatorServer: String,
|
||||||
private val rotatorPort: Int,
|
private val rotatorPort: Int,
|
||||||
private val frequencyServer: String,
|
private val frequencyServer: String,
|
||||||
private val frequencyPort: Int
|
private val frequencyPort: Int,
|
||||||
|
private val frequencyOffsetHz: Long = 0L
|
||||||
) : IReporter {
|
) : IReporter {
|
||||||
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
private val connectionMutex = Mutex()
|
||||||
|
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||||
|
|
||||||
private var rotatorSocket: SocketChannel? = null
|
private var rotatorSocket: SocketChannel? = null
|
||||||
private var rotatorConnected = false
|
private var rotatorConnected = false
|
||||||
private var rotatorConnecting = false
|
|
||||||
|
|
||||||
private var frequencySocket: SocketChannel? = null
|
private var frequencySocket: SocketChannel? = null
|
||||||
private var frequencyConnected = false
|
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) {
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
@@ -58,46 +72,43 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long) {
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
reporterScope.launch {
|
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||||
ensureFrequencyConnected()
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
if (!frequencyConnected) return@launch
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
val command = format
|
)
|
||||||
.replace($$"$FREQ", frequency.toString())
|
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||||
.unescapeControlChars()
|
val command = format
|
||||||
write(frequencySocket, command) { frequencyConnected = false }
|
.replace($$"$FREQ", correctedFreq.toString())
|
||||||
}
|
.unescapeControlChars()
|
||||||
|
frequencyCommands.trySend(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureRotatorConnected() {
|
private suspend fun ensureRotatorConnected() {
|
||||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
rotatorConnecting = true
|
resetRotatorConnection()
|
||||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
rotatorConnected = true
|
rotatorConnected = true
|
||||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter rotator connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
rotatorConnected = false
|
resetRotatorConnection()
|
||||||
} finally {
|
|
||||||
rotatorConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureFrequencyConnected() {
|
private suspend fun ensureFrequencyConnected() {
|
||||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
frequencyConnecting = true
|
resetFrequencyConnection()
|
||||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
frequencyConnected = true
|
frequencyConnected = true
|
||||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter frequency connect error: ${e.message}")
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
frequencyConnected = false
|
resetFrequencyConnection()
|
||||||
} finally {
|
|
||||||
frequencyConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +117,9 @@ class NetworkReporter(
|
|||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
socket?.write(buffer)
|
while (buffer.hasRemaining()) {
|
||||||
|
socket?.write(buffer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
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 =
|
private fun String.unescapeControlChars(): String =
|
||||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
}
|
}
|
||||||
@@ -22,13 +22,17 @@ import android.content.Context
|
|||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
|
||||||
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
@@ -66,12 +70,14 @@ import okhttp3.OkHttpClient
|
|||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
private val localSource = provideLocalSource()
|
private val localSource = provideLocalSource()
|
||||||
|
private val remoteSource = provideRemoteSource()
|
||||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||||
override val settingsRepo = provideSettingsRepo()
|
override val settingsRepo = provideSettingsRepo()
|
||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
|
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
@@ -103,7 +109,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
rc.rotatorAddress,
|
rc.rotatorAddress,
|
||||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
rc.frequencyAddress,
|
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)
|
return SensorsRepo(manager, displayManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
|
||||||
|
try {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
|
||||||
|
} catch (_: SecurityException) {}
|
||||||
|
}
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
val remoteSource = provideRemoteSource()
|
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideLocalSource(): ILocalSource {
|
private fun provideLocalSource(): ILocalSource {
|
||||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
|
||||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
|
||||||
return LocalSource(database.look4SatDao())
|
return LocalSource(database.look4SatDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
|
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
|
||||||
|
// Data providers ask clients to identify themselves, so that they can reach out to the
|
||||||
|
// developer instead of silently blocking every user of the app behind a misbehaving request
|
||||||
|
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||||
|
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
|
||||||
|
}.build()
|
||||||
|
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -164,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
val appVersionName = packageInfo.versionName ?: "4.0.4"
|
||||||
|
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||||
|
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+209
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-30
@@ -23,7 +23,6 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -40,14 +39,13 @@ class DatabaseRepo(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : IDatabaseRepo {
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
private val customSourceType = "Other"
|
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||||
localSource.insertEntries(entries)
|
insertFresherEntries(entries)
|
||||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
// report what the file contained: a valid file holding only stale data is not an error
|
||||||
importedCount = entries.size
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -58,7 +56,7 @@ class DatabaseRepo(
|
|||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertRadios(transceivers)
|
localSource.insertRadios(transceivers, isCustom = true)
|
||||||
importedCount = transceivers.size
|
importedCount = transceivers.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -66,32 +64,40 @@ class DatabaseRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
||||||
putAll(Sources.satelliteDataUrls)
|
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
}.filterValues { it.isNotBlank() }
|
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||||
val radioUrls = buildMap {
|
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
putAll(Sources.transceiversDataUrls)
|
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
val tleResults = tleJobs.awaitAll()
|
||||||
}.filterValues { it.isNotBlank() }
|
val radioResults = radioJobs.awaitAll()
|
||||||
// launch all network requests concurrently
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
settingsRepo.updateDataSourcesStatus(
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
// parse fetched data concurrently and associate with types
|
)
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
// parse fetched data concurrently, merging satellites by freshness instead of source order
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
val parsedPerSource = tleResults.map { (url, result) ->
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, 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
|
// insert parsed data into the database
|
||||||
localSource.insertEntries(importedEntries)
|
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||||
localSource.insertRadios(importedRadios)
|
// transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||||
|
// Imported ones are kept: no source can refresh them, so nothing would bring them back
|
||||||
|
if (importedRadios.isNotEmpty()) {
|
||||||
|
localSource.deleteManagedRadios()
|
||||||
|
localSource.insertRadios(importedRadios, isCustom = false)
|
||||||
|
}
|
||||||
|
// keep the previous timestamp when every source failed, so the next launch retries
|
||||||
|
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
||||||
|
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
||||||
|
if (hasFetchedData) pruneStaleEntries()
|
||||||
|
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||||
@@ -127,6 +133,62 @@ class DatabaseRepo(
|
|||||||
line.count { it == ',' } >= 4
|
line.count { it == ',' } >= 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the data of every source: orbital elements always come from the set with the newest
|
||||||
|
* epoch, while the name comes from the first source that provides it. Source order is thus a
|
||||||
|
* naming preference only, which also keeps names stable when sources leapfrog each other.
|
||||||
|
*/
|
||||||
|
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
|
||||||
|
val preferredNames = mutableMapOf<Int, String>()
|
||||||
|
val freshestEntries = mutableMapOf<Int, OrbitalData>()
|
||||||
|
sourceEntries.forEach { entries ->
|
||||||
|
entries.forEach { entry ->
|
||||||
|
val name = entry.name.trim()
|
||||||
|
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
|
||||||
|
val current = freshestEntries[entry.catnum]
|
||||||
|
if (current == null || entry.epochDaynum > current.epochDaynum) {
|
||||||
|
freshestEntries[entry.catnum] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return freshestEntries.values.map { entry ->
|
||||||
|
val name = preferredNames[entry.catnum]
|
||||||
|
if (name == null || name == entry.name) entry else entry.copy(name = name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
|
||||||
|
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
|
||||||
|
val storedEpochs = localSource.getEntriesEpochs()
|
||||||
|
val (fresherEntries, staleEntries) = entries.partition { entry ->
|
||||||
|
val storedEpoch = storedEpochs[entry.catnum]
|
||||||
|
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
|
||||||
|
}
|
||||||
|
localSource.insertEntries(fresherEntries)
|
||||||
|
// a reordered source list has to rename satellites right away, even the ones holding
|
||||||
|
// elements that are newer than the ones just parsed
|
||||||
|
if (staleEntries.isNotEmpty()) {
|
||||||
|
val storedNames = localSource.getEntriesNames()
|
||||||
|
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
|
||||||
|
if (renamedEntries.isNotEmpty()) {
|
||||||
|
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
|
||||||
|
* disappeared from every catalog. Manually imported data ages out the same way, and every
|
||||||
|
* source republishes active satellites well within that window.
|
||||||
|
*/
|
||||||
|
private suspend fun pruneStaleEntries() {
|
||||||
|
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
|
||||||
|
val staleIds = localSource.getEntriesEpochs()
|
||||||
|
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
|
||||||
|
.keys.toList()
|
||||||
|
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
settingsRepo.updateDatabaseState(
|
settingsRepo.updateDatabaseState(
|
||||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||||
|
|||||||
+21
-15
@@ -34,9 +34,11 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -63,19 +65,23 @@ class SatelliteRepo(
|
|||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
settingsRepo.selectedIds.collect { selectedIds ->
|
combine(
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
settingsRepo.selectedIds,
|
||||||
val settings = settingsRepo.passesSettings.value
|
settingsRepo.stationPosition
|
||||||
calculatePasses(
|
) { selectedIds, _ -> selectedIds }
|
||||||
time = System.currentTimeMillis(),
|
.collect { selectedIds ->
|
||||||
hoursAhead = settings.hoursAhead,
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
minElevation = settings.minElevation,
|
val settings = settingsRepo.passesSettings.value
|
||||||
aosStartMinute = settings.aosStartMinute,
|
calculatePasses(
|
||||||
aosEndMinute = settings.aosEndMinute,
|
time = System.currentTimeMillis(),
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
hoursAhead = settings.hoursAhead,
|
||||||
modes = settings.selectedModes
|
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 {
|
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||||
@@ -158,7 +164,7 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newPasses.sortBy { it.aosTime }
|
newPasses.sortBy { it.aosTime }
|
||||||
delay(1000) // Simulate loading time for better UX
|
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||||
_passes.update { newPasses }
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
@@ -176,7 +182,7 @@ class SatelliteRepo(
|
|||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
aosMinute in aosStartMinute..aosEndMinute
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
} else {
|
} else {
|
||||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||||
}
|
}
|
||||||
return if (invertAosTimeWindow) !inRange else inRange
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-13
@@ -38,16 +38,16 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
|
||||||
private val currentQuery = MutableStateFlow("")
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
// Resolve type IDs once when types change, then filter items reactively.
|
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||||
|
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
val ids = localSource.getIdsWithModes(list)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
itemsWithModes.map { items ->
|
||||||
|
filterByQuery(items, query).sortedWith(
|
||||||
|
compareByDescending<SatItem> { it.isSelected }
|
||||||
|
.thenBy { it.name }
|
||||||
|
.thenBy { it.catnum }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||||
|
|
||||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
override fun getModesList() = Sources.satelliteModes
|
||||||
removeAt(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
@@ -73,9 +77,8 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTypes(types: List<String>) {
|
override suspend fun setModes(modes: List<String>) {
|
||||||
currentTypes.value = types
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
settingsRepo.setSelectedTypes(types)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
|
|||||||
+151
-55
@@ -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.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String,
|
||||||
|
override val appVersionCode: Long,
|
||||||
) : ISettingsRepo, LocationListenerCompat {
|
) : ISettingsRepo, LocationListenerCompat {
|
||||||
|
|
||||||
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||||
@@ -67,9 +72,9 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyAddress = "frequencyAddress"
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedSatModes = "selectedSatModes"
|
||||||
private val keySelectedModes = "selectedModes"
|
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
@@ -83,21 +88,38 @@ class SettingsRepo(
|
|||||||
private val keyStationTimestamp = "stationTimestamp"
|
private val keyStationTimestamp = "stationTimestamp"
|
||||||
private val keyUpdateTimestamp = "updateTimestamp"
|
private val keyUpdateTimestamp = "updateTimestamp"
|
||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keySatellitesUrls = "satellitesUrls"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
|
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
|
||||||
|
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
|
||||||
|
private val keySettingsVersion = "settingsVersion"
|
||||||
private val separatorComma = ","
|
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
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -105,10 +127,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) {
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
val typesString = types.joinToString(separatorComma)
|
val modesString = modes.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||||
_typesSelection.value = types
|
_satelliteModeSelection.value = modes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -117,10 +139,10 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedSatModes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||||
if (typesString.isNullOrEmpty()) return emptyList()
|
if (modesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return modesString.split(separatorComma).sorted()
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -135,7 +157,6 @@ class SettingsRepo(
|
|||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,16 +167,13 @@ class SettingsRepo(
|
|||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
|
||||||
return PassesSettings(
|
return PassesSettings(
|
||||||
showDeepSpace,
|
showDeepSpace,
|
||||||
hoursAhead,
|
hoursAhead,
|
||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow,
|
invertAosTimeWindow
|
||||||
selectedModes
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -235,25 +253,6 @@ class SettingsRepo(
|
|||||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||||
|
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
|
||||||
val idsSet = mutableSetOf<Int>()
|
|
||||||
types.forEach { type ->
|
|
||||||
val typeString = preferences.getString("type$type", null)
|
|
||||||
val typeIds = if (typeString.isNullOrBlank()) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
typeString.split(separatorComma).map { it.toInt() }
|
|
||||||
}
|
|
||||||
idsSet.addAll(typeIds)
|
|
||||||
}
|
|
||||||
return idsSet.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
|
||||||
if (type == "All") return
|
|
||||||
val typesString = ids.joinToString(separatorComma)
|
|
||||||
preferences.edit { putString("type$type", typesString) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||||
@@ -298,6 +297,10 @@ class SettingsRepo(
|
|||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
|
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||||
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyRotatorState, settings.rotatorState)
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
@@ -307,6 +310,7 @@ class SettingsRepo(
|
|||||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
putString(keyFrequencyPort, settings.frequencyPort)
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
|
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
@@ -315,7 +319,7 @@ class SettingsRepo(
|
|||||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
}
|
}
|
||||||
_rcSettings.value = settings
|
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -327,6 +331,8 @@ class SettingsRepo(
|
|||||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
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),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
@@ -356,6 +362,8 @@ class SettingsRepo(
|
|||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||||
|
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -372,7 +380,9 @@ class SettingsRepo(
|
|||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
|
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||||
|
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -381,21 +391,73 @@ class SettingsRepo(
|
|||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: 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 {
|
preferences.edit {
|
||||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
putString(keyTleUrl, settings.tleUrl)
|
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
val (satUrls, satEnabled) = parseSources(
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
preferences.getString(keySatellitesUrls, null),
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
preferences.getString(keySatellitesUrlsEnabled, null),
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
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
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -435,5 +497,39 @@ class SettingsRepo(
|
|||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
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
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
return selectedOrbitalObjects
|
return selectedOrbitalObjects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) =
|
||||||
|
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
|
||||||
|
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||||
|
|
||||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
|
|
||||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||||
@@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||||
|
|
||||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||||
|
|
||||||
|
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||||
@@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
|
||||||
|
|
||||||
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ package com.rtbishop.look4sat.core.data.source
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.ByteArrayInputStream
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
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 {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().use { response ->
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
if (!response.isSuccessful) return@withContext null
|
if (!response.isSuccessful) {
|
||||||
ByteArrayInputStream(response.body.bytes())
|
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) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream 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
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+225
-26
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -41,6 +42,7 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class DatabaseRepoTest {
|
class DatabaseRepoTest {
|
||||||
@@ -48,6 +50,10 @@ class DatabaseRepoTest {
|
|||||||
private val dispatcher = StandardTestDispatcher()
|
private val dispatcher = StandardTestDispatcher()
|
||||||
private val dataParser = DataParser(dispatcher)
|
private val dataParser = DataParser(dispatcher)
|
||||||
|
|
||||||
|
// fixtures carry a current epoch: stale entries are pruned on every remote update
|
||||||
|
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
|
||||||
|
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||||
val uri = "content://look4sat/import/satellites"
|
val uri = "content://look4sat/import/satellites"
|
||||||
@@ -62,7 +68,6 @@ class DatabaseRepoTest {
|
|||||||
|
|
||||||
assertEquals(1, localSource.insertedEntries.size)
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
|
||||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +96,8 @@ class DatabaseRepoTest {
|
|||||||
}
|
}
|
||||||
val settingsRepo = FakeSettingsRepo(
|
val settingsRepo = FakeSettingsRepo(
|
||||||
dataSources = DataSourcesSettings(
|
dataSources = DataSourcesSettings(
|
||||||
useCustomTLE = true,
|
satelliteUrls = listOf(customCsvUrl),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = customCsvUrl,
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
@@ -102,33 +105,200 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update 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 = """
|
private fun validCsvStream(): InputStream = """
|
||||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
private fun validTleStream(): InputStream = """
|
private fun validTleStream(): InputStream = """
|
||||||
ISS (ZARYA)
|
ISS (ZARYA)
|
||||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun 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 {
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
override suspend fun getNetworkStream(url: String): NetworkResult {
|
||||||
|
val stream = networkStreams[url]?.invoke()
|
||||||
|
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
private class FakeLocalSource : ILocalSource {
|
||||||
val insertedEntries = mutableListOf<OrbitalData>()
|
val insertedEntries = mutableListOf<OrbitalData>()
|
||||||
private val insertedRadios = mutableListOf<SatRadio>()
|
val insertedRadios = mutableListOf<SatRadio>()
|
||||||
|
private val customRadios = mutableListOf<String>()
|
||||||
|
|
||||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||||
|
|
||||||
@@ -136,6 +306,21 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesEpochs(): Map<Int, Double> =
|
||||||
|
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames(): Map<Int, String> =
|
||||||
|
insertedEntries.associate { entry -> entry.catnum to entry.name }
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
|
||||||
|
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
|
||||||
|
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
|
||||||
|
insertedEntries.removeAll { entry -> entry.catnum in ids }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||||
insertedEntries += entries
|
insertedEntries += entries
|
||||||
}
|
}
|
||||||
@@ -150,25 +335,34 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
|
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
|
||||||
insertedRadios += radios
|
insertedRadios += radios
|
||||||
|
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
|
||||||
|
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRadios() {
|
override suspend fun deleteRadios() {
|
||||||
insertedRadios.clear()
|
insertedRadios.clear()
|
||||||
|
customRadios.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() {
|
||||||
|
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||||
|
|
||||||
override val appVersionName: String = "test"
|
override val appVersionName: String = "test"
|
||||||
|
override val appVersionCode: Long = 1L
|
||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
@@ -176,7 +370,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
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 dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
|
|
||||||
|
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
|
|
||||||
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) = Unit
|
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
@@ -203,11 +397,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean = true
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
|
||||||
satelliteTypeIdsByType[type] = ids
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) {
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
databaseState.value = state
|
databaseState.value = state
|
||||||
@@ -221,14 +410,24 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
dataSourcesSettings.value = settings
|
dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
dataSourcesStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
return DataSourcesSettings(
|
return DataSourcesSettings(
|
||||||
useCustomTLE = false,
|
satelliteUrls = emptyList(),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = "",
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SelectionRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(25544, "ISS (ZARYA)", false),
|
||||||
|
SatItem(40967, "TIANGONG", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setModes(listOf("REMOVED_MODE"))
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
||||||
|
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(44444, "Zeta", false),
|
||||||
|
SatItem(25544, "Alpha", false),
|
||||||
|
SatItem(40967, "Beta", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setSelection(listOf(40967), true)
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
||||||
|
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource(
|
||||||
|
private val entries: List<SatItem>
|
||||||
|
) : ILocalSource {
|
||||||
|
override suspend fun getEntriesTotal(): Int = entries.size
|
||||||
|
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = entries
|
||||||
|
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One public AMSAT satellite status report submission. */
|
||||||
|
data class AmSatReportSubmission(
|
||||||
|
val name: String,
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedAtUtcMillis: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of submitting an AMSAT satellite status report. */
|
||||||
|
data class AmSatReportSubmitResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String = "",
|
||||||
|
val reportId: String? = null
|
||||||
|
)
|
||||||
@@ -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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
|
||||||
|
* missing optional field should not cost the whole transceiver. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SatRadio(
|
data class SatRadio(
|
||||||
@SerialName("uuid") val uuid: String,
|
@SerialName("uuid") val uuid: String,
|
||||||
@SerialName("description") val info: String,
|
@SerialName("description") val info: String = "",
|
||||||
@SerialName("alive") val isAlive: Boolean,
|
@SerialName("alive") val isAlive: Boolean = true,
|
||||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
@SerialName("downlink_low") val downlinkLow: Long? = null,
|
||||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
@SerialName("downlink_high") val downlinkHigh: Long? = null,
|
||||||
@SerialName("mode") val downlinkMode: String?,
|
@SerialName("mode") val downlinkMode: String? = null,
|
||||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
@SerialName("uplink_low") val uplinkLow: Long? = null,
|
||||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
@SerialName("uplink_high") val uplinkHigh: Long? = null,
|
||||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
@SerialName("uplink_mode") val uplinkMode: String? = null,
|
||||||
@SerialName("invert") val isInverted: Boolean,
|
@SerialName("invert") val isInverted: Boolean = false,
|
||||||
@SerialName("norad_cat_id") val catnum: Int?
|
@SerialName("norad_cat_id") val catnum: Int? = null
|
||||||
)
|
)
|
||||||
@@ -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 minElevation: Double,
|
||||||
val aosStartMinute: Int = 0,
|
val aosStartMinute: Int = 0,
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
val invertAosTimeWindow: Boolean = false,
|
val invertAosTimeWindow: Boolean = false
|
||||||
val selectedModes: List<String>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -42,6 +41,7 @@ data class RCSettings(
|
|||||||
val frequencyAddress: String,
|
val frequencyAddress: String,
|
||||||
val frequencyPort: String,
|
val frequencyPort: String,
|
||||||
val frequencyFormat: String,
|
val frequencyFormat: String,
|
||||||
|
val frequencyOffsetHz: Long = 0L,
|
||||||
val bluetoothRotatorState: Boolean,
|
val bluetoothRotatorState: Boolean,
|
||||||
val bluetoothRotatorFormat: String,
|
val bluetoothRotatorFormat: String,
|
||||||
val bluetoothRotatorName: String,
|
val bluetoothRotatorName: String,
|
||||||
@@ -62,15 +62,26 @@ data class OtherSettings(
|
|||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0
|
val highElevation: Double = 45.0,
|
||||||
|
val radarCompassOffset: Float = 0f,
|
||||||
|
val radarCompassOffsetElev: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
|
||||||
|
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
|
||||||
|
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
|
||||||
|
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
|
||||||
|
*/
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val useCustomTLE: Boolean,
|
val satelliteUrls: List<String>,
|
||||||
val useCustomTransceivers: Boolean,
|
val transceiversUrls: List<String>,
|
||||||
val tleUrl: String,
|
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||||
val transceiversUrl: String
|
val transceiversEnabled: List<Boolean> = emptyList()
|
||||||
)
|
) {
|
||||||
|
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||||
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
|
}
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
|
|||||||
+6
-2
@@ -424,8 +424,12 @@ object CelestialComputer {
|
|||||||
}
|
}
|
||||||
if (sunrise == 0.0) sunrise = daynum
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
daynum = sunrise
|
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||||
|
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||||
|
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||||
|
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||||
|
daynum = sunrise + 0.001
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
|||||||
+29
-17
@@ -37,30 +37,42 @@ data class OrbitalData(
|
|||||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||||
|
|
||||||
|
/** Absolute age of this element set, used to pick the freshest data among several sources. */
|
||||||
|
val epochDaynum: Double = epochToDaynum(epoch)
|
||||||
|
|
||||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||||
|
|
||||||
/** Check if satellite has likely decayed by the given time. */
|
/** Check if satellite has likely decayed by the given time. */
|
||||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||||
if (ndot == 0.0) return false
|
if (ndot == 0.0) return false
|
||||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||||
val epochDaynum = epochToDaynum(epoch)
|
|
||||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun epochToDaynum(epoch: Double): Double {
|
companion object {
|
||||||
var year = kotlin.math.floor(epoch * 1E-3)
|
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||||
val day = (epoch * 1E-3 - year) * 1000.0
|
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
|
||||||
year = if (year < 57) year + 2000 else year + 1900
|
|
||||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
|
||||||
val jan1Jd = julianDateOfYear(year)
|
|
||||||
return jan1Jd + day - 2444238.5
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun julianDateOfYear(theYear: Double): Double {
|
/**
|
||||||
val aYear = theYear - 1
|
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||||
val b = 2 - a + a / 4
|
* freshness checks must always go through this conversion.
|
||||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
*/
|
||||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
fun epochToDaynum(epoch: Double): Double {
|
||||||
|
var year = kotlin.math.floor(epoch * 1E-3)
|
||||||
|
val day = (epoch * 1E-3 - year) * 1000.0
|
||||||
|
year = if (year < 57) year + 2000 else year + 1900
|
||||||
|
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||||
|
val jan1Jd = julianDateOfYear(year)
|
||||||
|
return jan1Jd + day - 2444238.5
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun julianDateOfYear(theYear: Double): Double {
|
||||||
|
val aYear = theYear - 1
|
||||||
|
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||||
|
val b = 2 - a + a / 4
|
||||||
|
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||||
|
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+14
@@ -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
|
||||||
|
}
|
||||||
+2
@@ -29,6 +29,7 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val amSatRepo: IAmSatRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
@@ -39,6 +40,7 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
|
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentTypes(): List<String>
|
fun getCurrentModes(): List<String>
|
||||||
fun getTypesList(): List<String>
|
fun getModesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||||
suspend fun setTypes(types: List<String>)
|
suspend fun setModes(modes: List<String>)
|
||||||
suspend fun setQuery(query: String)
|
suspend fun setQuery(query: String)
|
||||||
suspend fun setSelection(selectAll: Boolean)
|
suspend fun setSelection(selectAll: Boolean)
|
||||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||||
|
|||||||
+18
-4
@@ -29,12 +29,13 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
interface ISettingsRepo {
|
interface ISettingsRepo {
|
||||||
|
|
||||||
val appVersionName: String
|
val appVersionName: String
|
||||||
|
val appVersionCode: Long
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
val selectedTypes: StateFlow<List<String>>
|
val selectedSatModes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedTypes(types: List<String>)
|
fun setSelectedSatModes(modes: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -51,8 +52,6 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Database update settings
|
//region # Database update settings
|
||||||
val databaseState: StateFlow<DatabaseState>
|
val databaseState: StateFlow<DatabaseState>
|
||||||
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
|
||||||
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
|
||||||
fun updateDatabaseState(state: DatabaseState)
|
fun updateDatabaseState(state: DatabaseState)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -73,8 +72,23 @@ interface ISettingsRepo {
|
|||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||||
|
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//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 getEntriesTotal(): Int
|
||||||
suspend fun getEntriesList(): List<SatItem>
|
suspend fun getEntriesList(): List<SatItem>
|
||||||
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
||||||
|
suspend fun getEntriesEpochs(): Map<Int, Double>
|
||||||
|
suspend fun getEntriesNames(): Map<Int, String>
|
||||||
|
suspend fun renameEntries(names: Map<Int, String>)
|
||||||
|
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
suspend fun insertRadios(radios: List<SatRadio>)
|
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
+12
-1
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||||
|
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||||
|
companion object {
|
||||||
|
/** Code used when a request fails before any HTTP response is received. */
|
||||||
|
const val CONNECTION_ERROR = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): NetworkResult
|
||||||
|
suspend fun getAmSatCatalog(): String?
|
||||||
|
suspend fun 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
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
val satelliteDataUrls = mapOf(
|
// Ordered from the most specific to the most generic source: the first source that knows a
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
// satellite gets to name it, with the full catalog acting as the fallback.
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
val satelliteDataUrls = listOf(
|
||||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
"live.ariss.org/iss.txt",
|
||||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
"r4uab.ru/satonline.txt",
|
||||||
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
|
"mmccants.org/tles/classfd.zip",
|
||||||
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
|
"amsat.org/tle/current/nasabare.txt",
|
||||||
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
|
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
|
"db.satnogs.org/api/tle/?format=3le"
|
||||||
"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
|
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = mapOf(
|
val transceiversDataUrls = listOf(
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
"r4uab.ru/transmitters.json",
|
||||||
|
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
|
)
|
||||||
|
val satelliteModes = listOf(
|
||||||
|
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||||
|
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
|
||||||
|
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
|
||||||
|
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
|
||||||
|
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
|
||||||
|
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
|
||||||
|
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
coerceInputValues = true
|
coerceInputValues = true
|
||||||
}
|
}
|
||||||
|
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
|
private val celestrakCSVColumns = mapOf(
|
||||||
|
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
|
||||||
|
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
|
||||||
|
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||||
stream.bufferedReader().useLines { lines ->
|
stream.bufferedReader().useLines { lines ->
|
||||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
val iterator = lines.iterator()
|
||||||
|
if (!iterator.hasNext()) return@useLines emptyList()
|
||||||
|
val columns = parseCSVColumns(iterator.next())
|
||||||
|
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,40 +67,68 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
|
||||||
val name = values[0]
|
fun value(column: String) = values[columns.getValue(column)].trim()
|
||||||
val timestamp = values[2]
|
fun optionalValue(column: String) =
|
||||||
val year = timestamp.substring(0, 4)
|
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
val month = timestamp.substring(5, 7).toInt()
|
|
||||||
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
|
||||||
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
|
|
||||||
val day = dayInt.toString().padStart(3, '0')
|
|
||||||
val hour = timestamp.substring(11, 13).toInt() * 3600000
|
|
||||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
|
||||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
|
||||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
|
||||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
|
||||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = name,
|
name = value("OBJECT_NAME"),
|
||||||
epoch = epoch,
|
epoch = parseTimestamp(value("EPOCH")),
|
||||||
meanmo = values[3].toDouble(),
|
meanmo = value("MEAN_MOTION").toDouble(),
|
||||||
eccn = values[4].toDouble(),
|
eccn = value("ECCENTRICITY").toDouble(),
|
||||||
incl = values[5].toDouble(),
|
incl = value("INCLINATION").toDouble(),
|
||||||
raan = values[6].toDouble(),
|
raan = value("RA_OF_ASC_NODE").toDouble(),
|
||||||
argper = values[7].toDouble(),
|
argper = value("ARG_OF_PERICENTER").toDouble(),
|
||||||
meanan = values[8].toDouble(),
|
meanan = value("MEAN_ANOMALY").toDouble(),
|
||||||
catnum = values[11].toInt(),
|
catnum = parseCatnum(value("NORAD_CAT_ID")),
|
||||||
bstar = values[14].toDouble(),
|
bstar = optionalValue("BSTAR"),
|
||||||
ndot = values[15].toDouble()
|
ndot = optionalValue("MEAN_MOTION_DOT")
|
||||||
)
|
)
|
||||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OMM columns are located by name, as providers agree on the names but not on the order.
|
||||||
|
* Falls back to the Celestrak layout when the header is missing or unrecognized.
|
||||||
|
*/
|
||||||
|
private fun parseCSVColumns(header: String): Map<String, Int> {
|
||||||
|
val columns = header.split(",").withIndex().associate { (index, name) ->
|
||||||
|
name.trim().trim('"').uppercase() to index
|
||||||
|
}
|
||||||
|
return if (columns.containsKey("NORAD_CAT_ID")) columns else celestrakCSVColumns
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 8601 timestamp as a TLE style YYDDD.ffffffff epoch. Fractional seconds are optional and
|
||||||
|
* a trailing timezone marker is ignored, as not every provider formats the epoch alike.
|
||||||
|
*/
|
||||||
|
private fun parseTimestamp(timestamp: String): Double {
|
||||||
|
val year = timestamp.substring(0, 4).toInt()
|
||||||
|
val month = timestamp.substring(5, 7).toInt()
|
||||||
|
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
||||||
|
val hours = timestamp.substring(11, 13).toInt()
|
||||||
|
val minutes = timestamp.substring(14, 16).toInt()
|
||||||
|
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble()
|
||||||
|
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0
|
||||||
|
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5:
|
||||||
|
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0.
|
||||||
|
*/
|
||||||
|
private fun parseCatnum(value: String): Int {
|
||||||
|
val catnum = value.trim()
|
||||||
|
if (catnum.first().isDigit()) return catnum.toInt()
|
||||||
|
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar())
|
||||||
|
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" }
|
||||||
|
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
||||||
val line1 = tle[1]
|
val line1 = tle[1]
|
||||||
val line2 = tle[2]
|
val line2 = tle[2]
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = tle[0].trim(),
|
name = tle[0].trim().removePrefix("0 "),
|
||||||
epoch = line1.substring(18, 32).toDouble(),
|
epoch = line1.substring(18, 32).toDouble(),
|
||||||
meanmo = line2.substring(52, 63).toDouble(),
|
meanmo = line2.substring(52, 63).toDouble(),
|
||||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||||
@@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
raan = line2.substring(17, 25).toDouble(),
|
raan = line2.substring(17, 25).toDouble(),
|
||||||
argper = line2.substring(34, 42).toDouble(),
|
argper = line2.substring(34, 42).toDouble(),
|
||||||
meanan = line2.substring(43, 51).toDouble(),
|
meanan = line2.substring(43, 51).toDouble(),
|
||||||
catnum = line1.substring(2, 7).trim().toInt(),
|
catnum = parseCatnum(line1.substring(2, 7)),
|
||||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||||
ndot = line1.substring(33, 43).trim().toDouble()
|
ndot = line1.substring(33, 43).trim().toDouble()
|
||||||
)
|
)
|
||||||
|
|||||||
+141
@@ -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
|
return kotlin.math.round(this * multiplier) / multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.aprsPasscode(): Int {
|
||||||
|
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
||||||
|
var hash = 0x73E2
|
||||||
|
var i = 0
|
||||||
|
while (i < callsign.length) {
|
||||||
|
hash = hash xor (callsign[i].code shl 8)
|
||||||
|
i++
|
||||||
|
if (i < callsign.length) {
|
||||||
|
hash = hash xor callsign[i].code
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hash and 0x7FFF
|
||||||
|
}
|
||||||
|
|
||||||
//fun String.getHash(type: String = "SHA-256"): String {
|
//fun String.getHash(type: String = "SHA-256"): String {
|
||||||
// val hexChars = "0123456789ABCDEF"
|
// val hexChars = "0123456789ABCDEF"
|
||||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class DataParserTest {
|
class DataParserTest {
|
||||||
@@ -60,8 +62,49 @@ class DataParserTest {
|
|||||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||||
assert(parsedList.size == 2)
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
|
||||||
|
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.bstar == 0.31985E-4)
|
||||||
|
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val parsedList = dataParser.parseCSVStream(csvStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
|
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
|
||||||
|
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
|
||||||
|
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
|
||||||
|
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
NAVSTAR 43
|
||||||
|
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
|
||||||
|
assert(sat.catnum == 270111)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -239,4 +282,10 @@ class DataParserTest {
|
|||||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `check APRS passcode calculation`() {
|
||||||
|
assert("M7LNB".aprsPasscode() == 12443)
|
||||||
|
assert("N0CALL".aprsPasscode() == 13023)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+273
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
-34
@@ -27,13 +27,18 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -47,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -71,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
@@ -143,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -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
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -227,8 +239,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
fun IconCard(
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
||||||
|
) {
|
||||||
|
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
||||||
|
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
||||||
|
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||||
}
|
}
|
||||||
@@ -284,51 +301,86 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SharedDialog(
|
fun InfoDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SharedDialog(
|
|
||||||
title: String,
|
title: String,
|
||||||
onDismissRequest: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onCancel: (() -> Unit)? = null,
|
onAccept: () -> Unit,
|
||||||
onAccept: (() -> Unit)? = null,
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
titleFontSize: Int = 16,
|
content: @Composable () -> Unit
|
||||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
|
||||||
content: @Composable (padding: Dp) -> Unit
|
|
||||||
) {
|
) {
|
||||||
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = padding, top = padding, end = padding)
|
.padding(start = padding, top = padding, end = padding)
|
||||||
) {
|
) {
|
||||||
if (onCancel != null) {
|
|
||||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
fontSize = titleFontSize.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
textAlign = titleTextAlign,
|
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,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
.padding(horizontal = padding)
|
||||||
)
|
)
|
||||||
if (onAccept != null) {
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
content(padding)
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||||
|
InfoDialog(
|
||||||
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onAccept = onDismiss
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pass_whatsnew_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +392,9 @@ private fun DialogShell(
|
|||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||||
|
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||||
|
val maxSheetHeight = containerHeight - statusBarHeight
|
||||||
val stopSheetFling = remember {
|
val stopSheetFling = remember {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
@@ -357,7 +412,7 @@ private fun DialogShell(
|
|||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -365,6 +420,7 @@ private fun DialogShell(
|
|||||||
verticalArrangement = Arrangement.spacedBy(padding),
|
verticalArrangement = Arrangement.spacedBy(padding),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = maxSheetHeight)
|
||||||
.nestedScroll(stopSheetFling)
|
.nestedScroll(stopSheetFling)
|
||||||
) {
|
) {
|
||||||
content(padding)
|
content(padding)
|
||||||
@@ -374,11 +430,11 @@ private fun DialogShell(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughHeight(): Boolean =
|
fun hasEnoughHeight(): Boolean =
|
||||||
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughWidth(): Boolean =
|
fun hasEnoughWidth(): Boolean =
|
||||||
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||||
|
|||||||
+272
@@ -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
|
||||||
+311
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-5
@@ -46,7 +46,7 @@ data object Spacing {
|
|||||||
val extraSmall = 6.dp
|
val extraSmall = 6.dp
|
||||||
val small = 8.dp
|
val small = 8.dp
|
||||||
val medium = 12.dp
|
val medium = 12.dp
|
||||||
val large = 16.dp
|
val large = 12.dp
|
||||||
val extraLarge = 24.dp
|
val extraLarge = 24.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
|
|||||||
onSecondary = Color(0xFFFFFFFF),
|
onSecondary = Color(0xFFFFFFFF),
|
||||||
secondaryContainer = Color(0xFFF1E1BB),
|
secondaryContainer = Color(0xFFF1E1BB),
|
||||||
onSecondaryContainer = Color(0xFF221B04),
|
onSecondaryContainer = Color(0xFF221B04),
|
||||||
|
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
|
||||||
|
onTertiary = Color(0xFFFFFFFF),
|
||||||
|
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
|
||||||
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFFFFF8F0),
|
background = Color(0xFFFFF8F0),
|
||||||
onBackground = Color(0xFF1E1B13),
|
onBackground = Color(0xFF1E1B13),
|
||||||
surface = Color(0xFFFFF8F0),
|
surface = Color(0xFFFFF8F0),
|
||||||
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
|
|||||||
onSecondary = Color(0xFF000000),
|
onSecondary = Color(0xFF000000),
|
||||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||||
// tertiary = Color(0xFF121212),
|
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
||||||
// onTertiary = Color(0xFF121212),
|
onTertiary = Color(0xFF000000),
|
||||||
// tertiaryContainer = Color(0xFF121212),
|
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
||||||
// onTertiaryContainer = Color(0xFF121212),
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFF121212),
|
background = Color(0xFF121212),
|
||||||
onBackground = Color(0xFFE0E0E0),
|
onBackground = Color(0xFFE0E0E0),
|
||||||
surface = Color(0xFF202020), // card background
|
surface = Color(0xFF202020), // card background
|
||||||
|
|||||||
+2
-2
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
|
|||||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
@Serializable
|
@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
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|||||||
@@ -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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="960"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="960">
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
android:pathData="M240,840L240,760Q240,760 256.5,750Q273,740 292.5,720.5Q312,701 328,670.5Q344,640 344,600Q344,589 342.5,579Q341,569 339,560L240,560L240,480L300,480Q279,447 259.5,410.5Q240,374 240,320Q240,228 304,164Q368,100 460,100Q531,100 586,139Q641,178 665,240L591,271Q576,231 540.5,205.5Q505,180 460,180Q402,180 361,221Q320,262 320,320Q320,368 344,400Q368,432 393,480L560,480L560,560L421,560Q423,569 423.5,579Q424,589 424,600Q424,650 406.5,690Q389,730 364,760L560,760Q600,760 621,739Q642,718 650,685L720,720Q709,775 663.5,807.5Q618,840 560,840L240,840Z" />
|
||||||
</vector>
|
</vector>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -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>
|
||||||
File renamed without changes.
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -13,12 +13,16 @@
|
|||||||
<string name="nav_prefs">Ajustes</string>
|
<string name="nav_prefs">Ajustes</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Tipo: %s</string>
|
<string name="sat_type_hint">Modos: %s</string>
|
||||||
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
<string name="sat_type_title">Seleccionar modos</string>
|
||||||
<string name="sat_search_hint">Id - Nombre</string>
|
<string name="sat_search_hint">Id - Nombre</string>
|
||||||
<string name="sat_search_clear">Vaciar</string>
|
<string name="sat_search_clear">Vaciar</string>
|
||||||
<string name="sat_clear_all">Vaciar todo</string>
|
<string name="sat_clear_all">Vaciar todo</string>
|
||||||
<string name="sat_select_all">Seleccionar todo</string>
|
<string name="sat_select_all">Seleccionar todo</string>
|
||||||
|
<string name="sat_group_selected">Seleccionados</string>
|
||||||
|
<string name="sat_group_available">Disponibles</string>
|
||||||
|
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">Disponibles (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">
|
<string name="sat_empty_list_message">
|
||||||
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
||||||
<string name="sat_warning_title">Alarma\!</string>
|
<string name="sat_warning_title">Alarma\!</string>
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
Esta aplicación incluye más de 9000 satélites.
|
Esta aplicación incluye más de 9000 satélites.
|
||||||
No tiene sentido rastrearlos todos a la vez.
|
No tiene sentido rastrearlos todos a la vez.
|
||||||
\n\nIntenta siempre limitar la lista a los que te
|
\n\nIntenta siempre limitar la lista a los que te
|
||||||
interesen mediante la búsqueda y el selector de tipos.</string>
|
interesen mediante la búsqueda y el selector de modos.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
<string name="pass_filter_title">Filtrar pases</string>
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||||
<string name="pass_modes_title">Tipo de modulación</string>
|
<string name="pass_modes_title">Seleccionar modos</string>
|
||||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -96,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
|
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
|
||||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
||||||
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
|
<string name="prefs_station_title">Ubicación de la estación</string>
|
||||||
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
<string name="prefs_station_lat_text">Latitud de estación</string>
|
||||||
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
<string name="prefs_station_lon_text">Longitud de estación</string>
|
||||||
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
<string name="prefs_locator_title">Localizador QTH</string>
|
||||||
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
<string name="prefs_locator_text">Posición por localizador</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Datos satelitales</string>
|
<string name="prefs_data_title">Datos satelitales</string>
|
||||||
<string name="prefs_data_entries">Satélites: %s</string>
|
<string name="prefs_data_entries">Satélites: %s</string>
|
||||||
@@ -110,39 +114,57 @@
|
|||||||
<string name="prefs_data_clear">Vaciar</string>
|
<string name="prefs_data_clear">Vaciar</string>
|
||||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
||||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</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_data_output_title">Salida de datos</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Red</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Salida de datos de red</string>
|
<string name="nav_radiocontrol">Control de radio</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="rc_settings_title">Control CAT</string>
|
||||||
|
<string name="rc_radio_model">Modelo de radio</string>
|
||||||
|
<string name="rc_tx_device_hint">Dirección BT TX</string>
|
||||||
|
<string name="rc_rx_device_hint">Dirección BT RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Nombre radio TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Nombre radio RX</string>
|
||||||
|
<string name="rc_enable_switch">Activar CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Salida de red</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
<string name="prefs_bt_title">Salida Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
|
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
||||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Otros ajustes</string>
|
<string name="prefs_other_title">Otros</string>
|
||||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
|||||||
@@ -13,19 +13,23 @@
|
|||||||
<string name="nav_prefs">Настройки</string>
|
<string name="nav_prefs">Настройки</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Тип: %s</string>
|
<string name="sat_type_hint">Режимы: %s</string>
|
||||||
<string name="sat_type_title">Выберите тип спутника</string>
|
<string name="sat_type_title">Выберите режимы</string>
|
||||||
<string name="sat_search_hint">Id - Название</string>
|
<string name="sat_search_hint">Id - Название</string>
|
||||||
<string name="sat_search_clear">Очистить</string>
|
<string name="sat_search_clear">Очистить</string>
|
||||||
<string name="sat_clear_all">Очистить</string>
|
<string name="sat_clear_all">Очистить</string>
|
||||||
<string name="sat_select_all">Выбрать</string>
|
<string name="sat_select_all">Выбрать</string>
|
||||||
|
<string name="sat_group_selected">Выбранные</string>
|
||||||
|
<string name="sat_group_available">Доступные</string>
|
||||||
|
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">Доступные (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||||
<string name="sat_warning_title">Внимание\!</string>
|
<string name="sat_warning_title">Внимание\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
В этом приложении перечислено более 9000 спутников.
|
В этом приложении перечислено более 9000 спутников.
|
||||||
Отслеживать их все одновременно бессмысленно.
|
Отслеживать их все одновременно бессмысленно.
|
||||||
\n\nВсегда старайтесь сузить список до тех,
|
\n\nВсегда старайтесь сузить список до тех,
|
||||||
которые вас интересуют, используя поиск и селектор типов.</string>
|
которые вас интересуют, используя поиск и выбор режимов.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||||
@@ -34,7 +38,7 @@
|
|||||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||||
<string name="pass_modes_title">Выберите тип модуляции</string>
|
<string name="pass_modes_title">Выберите режимы</string>
|
||||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||||
<string name="pass_altitude">Высота: %d км</string>
|
<string name="pass_altitude">Высота: %d км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -95,11 +99,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
||||||
<string name="prefs_station_title">Настройки местоположения</string>
|
<string name="prefs_station_title">Положение станции</string>
|
||||||
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
|
<string name="prefs_station_lat_text">Широта станции</string>
|
||||||
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
|
<string name="prefs_station_lon_text">Долгота станции</string>
|
||||||
<string name="prefs_locator_title">Настройки локатора QTH</string>
|
<string name="prefs_locator_title">Локатор QTH</string>
|
||||||
<string name="prefs_locator_text">Введите позицию через локатор</string>
|
<string name="prefs_locator_text">Позиция по локатору</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Данные спутников</string>
|
<string name="prefs_data_title">Данные спутников</string>
|
||||||
<string name="prefs_data_entries">Спутников: %s</string>
|
<string name="prefs_data_entries">Спутников: %s</string>
|
||||||
@@ -109,16 +113,35 @@
|
|||||||
<string name="prefs_data_clear">Очистить</string>
|
<string name="prefs_data_clear">Очистить</string>
|
||||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
||||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Свои источники данных</string>
|
<string name="prefs_data_sources_title">Свои источники</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</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_net_output">Сеть</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Вывод сетевых данных</string>
|
<string name="nav_radiocontrol">Управление радио</string>
|
||||||
|
|
||||||
|
<string name="rc_settings_title">CAT управление</string>
|
||||||
|
<string name="rc_radio_model">Модель радио</string>
|
||||||
|
<string name="rc_tx_device_hint">BT адрес TX</string>
|
||||||
|
<string name="rc_rx_device_hint">BT адрес RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Имя радио TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Имя радио RX</string>
|
||||||
|
<string name="rc_enable_switch">Включить CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Сетевой вывод</string>
|
||||||
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||||
@@ -126,22 +149,22 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Формат</string>
|
<string name="prefs_net_frequency_format_hint">Формат</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
<string name="prefs_bt_title">Вывод Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
|
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
|
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Формат</string>
|
<string name="prefs_bt_rotator_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
|
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
|
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Другие настройки</string>
|
<string name="prefs_other_title">Прочее</string>
|
||||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
||||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
|||||||
@@ -13,19 +13,23 @@
|
|||||||
<string name="nav_prefs">පසුතල</string>
|
<string name="nav_prefs">පසුතල</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">වර්ගය: %s</string>
|
<string name="sat_type_hint">මාදිලි: %s</string>
|
||||||
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
||||||
<string name="sat_search_hint">Id - නම</string>
|
<string name="sat_search_hint">Id - නම</string>
|
||||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||||
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
||||||
|
<string name="sat_group_selected">තෝරාගත්</string>
|
||||||
|
<string name="sat_group_available">තිබෙන</string>
|
||||||
|
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">
|
<string name="sat_empty_list_message">
|
||||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||||
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||||
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
|
||||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -96,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
||||||
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
||||||
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
|
<string name="prefs_station_title">පොළ පිහිටීම</string>
|
||||||
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්ෂාංශය සකසන්න</string>
|
<string name="prefs_station_lat_text">පොළ අක්ෂාංශය</string>
|
||||||
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
|
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
|
||||||
<string name="prefs_locator_title">QTH locator පසුතල</string>
|
<string name="prefs_locator_title">QTH locator</string>
|
||||||
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
|
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
||||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
||||||
@@ -110,39 +114,57 @@
|
|||||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
||||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
||||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">චන්ද්රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්ර ප්රතිස්ථාපනය කරන්න</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</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_data_output_title">දත්ත ප්රතිදානය</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">ජාලය</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
|
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
|
||||||
|
<string name="rc_settings_title">CAT පාලනය</string>
|
||||||
|
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
|
||||||
|
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
|
||||||
|
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
|
||||||
|
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
|
||||||
|
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
|
||||||
|
<string name="rc_enable_switch">CAT සබල කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
|
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
<string name="prefs_bt_title">Bluetooth ප්රතිදානය</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
|
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
|
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
|
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
<string name="prefs_other_title">වෙනත්</string>
|
||||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
||||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
||||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
||||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
|||||||
@@ -14,19 +14,23 @@
|
|||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
<string name="sat_type_hint">Modlar: %s</string>
|
||||||
<string name="sat_type_title">Uydu türü seçin</string>
|
<string name="sat_type_title">Modları seçin</string>
|
||||||
<string name="sat_search_hint">Id - Ad</string>
|
<string name="sat_search_hint">Id - Ad</string>
|
||||||
<string name="sat_search_clear">Temizle</string>
|
<string name="sat_search_clear">Temizle</string>
|
||||||
<string name="sat_clear_all">Tümünü temizle</string>
|
<string name="sat_clear_all">Tümünü temizle</string>
|
||||||
<string name="sat_select_all">Tümünü seç</string>
|
<string name="sat_select_all">Tümünü seç</string>
|
||||||
|
<string name="sat_group_selected">Seçilenler</string>
|
||||||
|
<string name="sat_group_available">Kullanılabilir</string>
|
||||||
|
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
|
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
|
||||||
<string name="sat_warning_title">Uyarı\!</string>
|
<string name="sat_warning_title">Uyarı\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
||||||
Hepsini aynı anda takip etmek anlamsızdır.
|
Hepsini aynı anda takip etmek anlamsızdır.
|
||||||
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
||||||
daraltmak için arama ve tür seçiciyi kullanın.</string>
|
daraltmak için arama ve mod seçiciyi kullanın.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||||
@@ -36,7 +40,7 @@
|
|||||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
<string name="pass_modes_title">Modları seçin</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
@@ -127,11 +131,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
||||||
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
||||||
<string name="prefs_station_title">İstasyon konumu ayarları</string>
|
<string name="prefs_station_title">İstasyon konumu</string>
|
||||||
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
|
<string name="prefs_station_lat_text">İstasyon enlemi</string>
|
||||||
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
|
<string name="prefs_station_lon_text">İstasyon boylamı</string>
|
||||||
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
|
<string name="prefs_locator_title">QTH konumlandırıcı</string>
|
||||||
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
|
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Uydu verisi</string>
|
<string name="prefs_data_title">Uydu verisi</string>
|
||||||
<string name="prefs_data_entries">Uydular: %s</string>
|
<string name="prefs_data_entries">Uydular: %s</string>
|
||||||
@@ -141,28 +145,35 @@
|
|||||||
<string name="prefs_data_clear">Temizle</string>
|
<string name="prefs_data_clear">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
<string name="prefs_data_sources_title">Özel kaynaklar</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</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_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Veri aktarımı</string>
|
<string name="prefs_data_output_title">Veri çıkışı</string>
|
||||||
<string name="prefs_net_output">Ağ</string>
|
<string name="prefs_net_output">Ağ</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
||||||
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
|
<string name="rc_settings_title">CAT kontrolü</string>
|
||||||
<string name="rc_radio_model">Radyo Modeli</string>
|
<string name="rc_radio_model">Radyo Modeli</string>
|
||||||
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
|
<string name="rc_tx_device_hint">TX BT adresi</string>
|
||||||
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
|
<string name="rc_rx_device_hint">RX BT adresi</string>
|
||||||
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
||||||
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
||||||
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
|
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Ağ veri çıkışı</string>
|
<string name="prefs_net_title">Ağ çıkışı</string>
|
||||||
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
||||||
@@ -170,22 +181,22 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
|
<string name="prefs_bt_title">Bluetooth çıkışı</string>
|
||||||
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
|
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
|
<string name="prefs_bt_rotator_output_hint">Biçim</string>
|
||||||
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
|
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
|
<string name="prefs_bt_frequency_output_hint">Biçim</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
<string name="prefs_other_title">Diğer</string>
|
||||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
<string name="prefs_other_switch_utc">UTC saati</string>
|
||||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
<string name="prefs_other_switch_sensors">Sensör</string>
|
||||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
|
|||||||
@@ -13,12 +13,16 @@
|
|||||||
<string name="nav_prefs">Налаштування</string>
|
<string name="nav_prefs">Налаштування</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Тип: %s</string>
|
<string name="sat_type_hint">Режими: %s</string>
|
||||||
<string name="sat_type_title">Виберіть тип супутника</string>
|
<string name="sat_type_title">Виберіть режими</string>
|
||||||
<string name="sat_search_hint">Id - Назва</string>
|
<string name="sat_search_hint">Id - Назва</string>
|
||||||
<string name="sat_search_clear">Очистити</string>
|
<string name="sat_search_clear">Очистити</string>
|
||||||
<string name="sat_clear_all">Очистити всі</string>
|
<string name="sat_clear_all">Очистити всі</string>
|
||||||
<string name="sat_select_all">Вибрати всі</string>
|
<string name="sat_select_all">Вибрати всі</string>
|
||||||
|
<string name="sat_group_selected">Обрані</string>
|
||||||
|
<string name="sat_group_available">Доступні</string>
|
||||||
|
<string name="sat_group_selected_count">Обрані (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">Доступні (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">
|
<string name="sat_empty_list_message">
|
||||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||||
<string name="sat_warning_title">Увага\!</string>
|
<string name="sat_warning_title">Увага\!</string>
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
У цьому додатку перелічено понад 9000 супутників.
|
У цьому додатку перелічено понад 9000 супутників.
|
||||||
Немає сенсу відстежувати їх усі одночасно.
|
Немає сенсу відстежувати їх усі одночасно.
|
||||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||||
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||||
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
<string name="pass_modes_title">Виберіть режими</string>
|
||||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||||
<string name="pass_altitude">Висота: %.0f км</string>
|
<string name="pass_altitude">Висота: %.0f км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -96,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||||
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
<string name="prefs_station_title">Позиція станції</string>
|
||||||
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
<string name="prefs_station_lat_text">Широта станції</string>
|
||||||
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
<string name="prefs_station_lon_text">Довгота станції</string>
|
||||||
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
<string name="prefs_locator_title">Локатор QTH</string>
|
||||||
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
<string name="prefs_locator_text">Позиція за локатором</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Супутникові дані</string>
|
<string name="prefs_data_title">Супутникові дані</string>
|
||||||
<string name="prefs_data_entries">Супутників: %s</string>
|
<string name="prefs_data_entries">Супутників: %s</string>
|
||||||
@@ -110,39 +114,57 @@
|
|||||||
<string name="prefs_data_clear">Очистити</string>
|
<string name="prefs_data_clear">Очистити</string>
|
||||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
||||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Власні джерела</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</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_data_output_title">Вивід даних</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Мережа</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Вивід мережевих даних</string>
|
<string name="nav_radiocontrol">Керування радіо</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="rc_settings_title">CAT керування</string>
|
||||||
|
<string name="rc_radio_model">Модель радіо</string>
|
||||||
|
<string name="rc_tx_device_hint">BT адреса TX</string>
|
||||||
|
<string name="rc_rx_device_hint">BT адреса RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Назва радіо TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Назва радіо RX</string>
|
||||||
|
<string name="rc_enable_switch">Увімкнути CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Мережевий вивід</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
<string name="prefs_bt_title">Вивід Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
|
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
|
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Інші налаштування</string>
|
<string name="prefs_other_title">Інше</string>
|
||||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
<string name="prefs_other_switch_sensors">Сенсори</string>
|
||||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
|||||||
@@ -12,16 +12,59 @@
|
|||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</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 -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">类型:%s</string>
|
<string name="sat_type_hint">模式:%s</string>
|
||||||
<string name="sat_type_title">选择卫星类型</string>
|
<string name="sat_type_title">选择模式</string>
|
||||||
<string name="sat_search_hint">Id - 名称</string>
|
<string name="sat_search_hint">Id - 名称</string>
|
||||||
<string name="sat_search_clear">清空</string>
|
<string name="sat_search_clear">清空</string>
|
||||||
<string name="sat_clear_all">全部清空</string>
|
<string name="sat_clear_all">全部清空</string>
|
||||||
<string name="sat_select_all">全选</string>
|
<string name="sat_select_all">全选</string>
|
||||||
|
<string name="sat_group_selected">已选择</string>
|
||||||
|
<string name="sat_group_available">可用</string>
|
||||||
|
<string name="sat_group_selected_count">已选择(%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">可用(%1$d)</string>
|
||||||
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||||
<string name="sat_warning_title">警告\!</string>
|
<string name="sat_warning_title">警告\!</string>
|
||||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">筛选过境</string>
|
<string name="pass_filter_title">筛选过境</string>
|
||||||
@@ -30,7 +73,7 @@
|
|||||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||||
<string name="pass_modes_title">选择调制类型</string>
|
<string name="pass_modes_title">选择模式</string>
|
||||||
<string name="pass_elevation">仰角:%.1f°</string>
|
<string name="pass_elevation">仰角:%.1f°</string>
|
||||||
<string name="pass_altitude">高度:%d km</string>
|
<string name="pass_altitude">高度:%d km</string>
|
||||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||||
@@ -56,6 +99,27 @@
|
|||||||
<string name="radar_string_no">否</string>
|
<string name="radar_string_no">否</string>
|
||||||
<string name="radar_string_yes">是</string>
|
<string name="radar_string_yes">是</string>
|
||||||
<string name="radar_visible">日照中</string>
|
<string name="radar_visible">日照中</string>
|
||||||
|
<string name="radar_doppler_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 -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
@@ -77,10 +141,16 @@
|
|||||||
<string name="prefs_donate_title">支持</string>
|
<string name="prefs_donate_title">支持</string>
|
||||||
<string name="prefs_privacy_title">隐私政策</string>
|
<string name="prefs_privacy_title">隐私政策</string>
|
||||||
|
|
||||||
|
<string name="settings_paired_devices">已配对的蓝牙设备:</string>
|
||||||
|
<string name="settings_split_mode">分频模式(仅 IC-705)</string>
|
||||||
|
<string name="prefs_lat_prefix">纬度:%s°</string>
|
||||||
|
<string name="prefs_lon_prefix">经度:%s°</string>
|
||||||
|
<string name="prefs_qth_prefix">QTH:%s</string>
|
||||||
|
|
||||||
<string name="prefs_updated_title">更新:%s</string>
|
<string name="prefs_updated_title">更新:%s</string>
|
||||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||||
|
|
||||||
<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_title">GPS 定位</string>
|
||||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
||||||
<string name="prefs_loc_input_title">输入位置</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_title">QTH 网格</string>
|
||||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||||
<string name="prefs_loc_success">位置更新成功</string>
|
<string name="prefs_loc_success">位置更新成功</string>
|
||||||
<string name="prefs_station_title">站位设置</string>
|
<string name="prefs_station_title">站点位置</string>
|
||||||
<string name="prefs_station_lat_text">输入站位的纬度</string>
|
<string name="prefs_station_lat_text">站点纬度</string>
|
||||||
<string name="prefs_station_lon_text">输入站位的经度</string>
|
<string name="prefs_station_lon_text">站点经度</string>
|
||||||
<string name="prefs_locator_title">QTH 网格</string>
|
<string name="prefs_locator_title">QTH 定位</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
|
<string name="prefs_locator_text">通过 QTH 设置站点位置</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
<string name="prefs_data_entries">卫星:%s</string>
|
||||||
@@ -102,16 +172,34 @@
|
|||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_success">更新成功</string>
|
<string name="prefs_data_update_success">更新成功</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
<string name="prefs_data_sources_title">自定义源</string>
|
||||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
<string name="prefs_data_sources_restore">恢复默认源</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</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_data_output_title">数据输出</string>
|
||||||
<string name="prefs_net_output">网络</string>
|
<string name="prefs_net_output">网络</string>
|
||||||
<string name="prefs_bt_output">蓝牙</string>
|
<string name="prefs_bt_output">蓝牙</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">网络数据输出</string>
|
<string name="nav_radiocontrol">电台控制</string>
|
||||||
|
<string name="rc_settings_title">CAT 控制</string>
|
||||||
|
<string name="rc_radio_model">电台型号</string>
|
||||||
|
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
|
||||||
|
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
|
||||||
|
<string name="rc_tx_name_hint">TX 电台名称</string>
|
||||||
|
<string name="rc_rx_name_hint">RX 电台名称</string>
|
||||||
|
<string name="rc_enable_switch">启用 CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">网络输出</string>
|
||||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||||
@@ -119,7 +207,7 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
<string name="prefs_bt_title">蓝牙输出</string>
|
||||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
|
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">数据格式</string>
|
<string name="prefs_bt_rotator_output_hint">数据格式</string>
|
||||||
@@ -129,12 +217,14 @@
|
|||||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">其他设置</string>
|
<string name="prefs_other_title">其他</string>
|
||||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
<string name="prefs_other_switch_utc">UTC 时间</string>
|
||||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
<string name="prefs_other_switch_update">自动更新数据</string>
|
||||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
||||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
||||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
||||||
|
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">仰角高亮</string>
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
@@ -151,4 +241,7 @@
|
|||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
|
<!-- Main screen -->
|
||||||
|
<string name="tracking_format">跟踪:%s</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -12,21 +12,26 @@
|
|||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
<string name="nav_status" translatable="false">AMSAT</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Type: %s</string>
|
<string name="sat_type_hint">Modes: %s</string>
|
||||||
<string name="sat_type_title">Select satellite type</string>
|
<string name="sat_type_title">Select modes</string>
|
||||||
<string name="sat_search_hint">Id - Name</string>
|
<string name="sat_search_hint">Id - Name</string>
|
||||||
<string name="sat_search_clear">Clear</string>
|
<string name="sat_search_clear">Clear</string>
|
||||||
<string name="sat_clear_all">Clear all</string>
|
<string name="sat_clear_all">Clear all</string>
|
||||||
<string name="sat_select_all">Select all</string>
|
<string name="sat_select_all">Select all</string>
|
||||||
|
<string name="sat_group_selected">Selected</string>
|
||||||
|
<string name="sat_group_available">Available</string>
|
||||||
|
<string name="sat_group_selected_count">Selected (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">Available (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
||||||
<string name="sat_warning_title">Warning\!</string>
|
<string name="sat_warning_title">Warning\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
There are over 9000 satellites listed in this app.
|
There are over 9000 satellites listed in this app.
|
||||||
It makes no sense to track them all at the same time.
|
It makes no sense to track them all at the same time.
|
||||||
\n\nAlways try to narrow down the list to only the ones
|
\n\nAlways try to narrow down the list to only the ones
|
||||||
you\'re interested in via search and types selector.</string>
|
you\'re interested in via search and the modes selector.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filter passes</string>
|
<string name="pass_filter_title">Filter passes</string>
|
||||||
@@ -36,7 +41,7 @@
|
|||||||
<string name="pass_filter_invert_time">Invert AOS window</string>
|
<string name="pass_filter_invert_time">Invert AOS window</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Select modulation type</string>
|
<string name="pass_modes_title">Select modes</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Elevation: %.1f°</string>
|
<string name="pass_elevation">Elevation: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
@@ -51,10 +56,55 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added required tweaks to support Android 17 (API 37)
|
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
|
||||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
\n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||||
|
\nSources now have priority - the top ones define satellite names and transceiver data.
|
||||||
|
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
|
||||||
|
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||||
|
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||||
|
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||||
|
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
|
<!-- AMSAT Status screen -->
|
||||||
|
<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 -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
<string name="radar_notify">Notify</string>
|
<string name="radar_notify">Notify</string>
|
||||||
@@ -80,6 +130,27 @@
|
|||||||
<string name="radar_string_no">No</string>
|
<string name="radar_string_no">No</string>
|
||||||
<string name="radar_string_yes">Yes</string>
|
<string name="radar_string_yes">Yes</string>
|
||||||
<string name="radar_visible">Visible</string>
|
<string name="radar_visible">Visible</string>
|
||||||
|
<string name="radar_doppler_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 -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
@@ -113,6 +184,12 @@
|
|||||||
<string name="prefs_privacy_title">Privacy Policy</string>
|
<string name="prefs_privacy_title">Privacy Policy</string>
|
||||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||||
|
|
||||||
|
<string name="settings_paired_devices">Paired Bluetooth Devices:</string>
|
||||||
|
<string name="settings_split_mode">Split mode (IC-705 only)</string>
|
||||||
|
<string name="prefs_lat_prefix">Lat: %s°</string>
|
||||||
|
<string name="prefs_lon_prefix">Lon: %s°</string>
|
||||||
|
<string name="prefs_qth_prefix">Qth: %s</string>
|
||||||
|
|
||||||
<string name="prefs_updated_title">Updated: %s</string>
|
<string name="prefs_updated_title">Updated: %s</string>
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||||
|
|
||||||
@@ -124,11 +201,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
||||||
<string name="prefs_loc_success">Position updated successfully</string>
|
<string name="prefs_loc_success">Position updated successfully</string>
|
||||||
<string name="prefs_station_title">Station position settings</string>
|
<string name="prefs_station_title">Station position</string>
|
||||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
<string name="prefs_station_lat_text">Station latitude</string>
|
||||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
<string name="prefs_station_lon_text">Station longitude</string>
|
||||||
<string name="prefs_locator_title">QTH locator settings</string>
|
<string name="prefs_locator_title">QTH locator</string>
|
||||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
<string name="prefs_locator_text">Set position via locator</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Satellite data</string>
|
<string name="prefs_data_title">Satellite data</string>
|
||||||
<string name="prefs_data_entries">Satellites: %s</string>
|
<string name="prefs_data_entries">Satellites: %s</string>
|
||||||
@@ -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_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Data sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_sat_title">Satellites data</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_transceivers_title">Transceivers data</string>
|
||||||
|
<string name="prefs_data_sources_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_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
@@ -153,38 +232,42 @@
|
|||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radio Control</string>
|
<string name="nav_radiocontrol">Radio Control</string>
|
||||||
<string name="rc_settings_title">CAT Radio Control</string>
|
<string name="rc_settings_title">CAT control</string>
|
||||||
<string name="rc_radio_model">Radio Model</string>
|
<string name="rc_radio_model">Radio Model</string>
|
||||||
<string name="rc_tx_device_hint">TX Radio BT Address</string>
|
<string name="rc_tx_device_hint">TX BT address</string>
|
||||||
<string name="rc_rx_device_hint">RX Radio BT Address</string>
|
<string name="rc_rx_device_hint">RX BT address</string>
|
||||||
<string name="rc_tx_name_hint">TX Radio Name</string>
|
<string name="rc_tx_name_hint">TX Radio Name</string>
|
||||||
<string name="rc_rx_name_hint">RX Radio Name</string>
|
<string name="rc_rx_name_hint">RX Radio Name</string>
|
||||||
<string name="rc_enable_switch">Enable CAT Control</string>
|
<string name="rc_enable_switch">Enable CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Network data output</string>
|
<string name="prefs_net_title">Network output</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Format</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_switch">Enable rotation output</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Device id</string>
|
<string name="prefs_bt_rotator_device_hint">Device ID</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Data format</string>
|
<string name="prefs_bt_rotator_output_hint">Format</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Device id</string>
|
<string name="prefs_bt_frequency_device_hint">Device ID</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Data format</string>
|
<string name="prefs_bt_frequency_output_hint">Format</string>
|
||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Other settings</string>
|
<string name="prefs_other_title">Other</string>
|
||||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
<string name="prefs_other_switch_utc">UTC time</string>
|
||||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
<string name="prefs_other_switch_update">Auto-update data</string>
|
||||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
<string name="prefs_other_switch_sensors">Use sensors</string>
|
||||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
<string name="prefs_other_switch_night_mode">Night filter</string>
|
||||||
|
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
<string name="prefs_highlight_low">Low < %1$d°</string>
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
@@ -203,4 +286,7 @@
|
|||||||
</string>
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
|
<!-- Main screen -->
|
||||||
|
<string name="tracking_format">Tracking: %s</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
* Added required tweaks to support Android 17 (API 37)
|
* Tweaked sun calculations, added tests by atsunatsu (#241)
|
||||||
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||||
|
* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||||
|
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||||
|
* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||||
@@ -21,6 +21,7 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Overlay
|
import org.osmdroid.views.overlay.Overlay
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
@@ -39,9 +40,11 @@ import kotlin.math.sin
|
|||||||
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
||||||
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
||||||
*
|
*
|
||||||
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
* Performance: we sample one column per stepPx pixels (default 4) and draw
|
||||||
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
||||||
* evaluations per row, which is imperceptible.
|
* evaluations per row, which is imperceptible. draw() is called on every
|
||||||
|
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
|
||||||
|
* single GeoPoint instance.
|
||||||
*/
|
*/
|
||||||
class MapNightOverlay : Overlay() {
|
class MapNightOverlay : Overlay() {
|
||||||
|
|
||||||
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
private val rect = RectF()
|
private val rect = RectF()
|
||||||
|
|
||||||
|
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
|
||||||
|
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||||
if (shadow) return
|
if (shadow) return
|
||||||
|
|
||||||
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
|
|||||||
val h = mapView.height
|
val h = mapView.height
|
||||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
||||||
|
|
||||||
|
// The map is never rotated, so latitude depends only on y and longitude only on x.
|
||||||
|
// Resolve the top/bottom latitudes once instead of once per column.
|
||||||
|
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
|
||||||
|
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
|
||||||
|
val sinLatTop = sin(latTopRad)
|
||||||
|
val cosLatTop = cos(latTopRad)
|
||||||
|
val sinLatBot = sin(latBotRad)
|
||||||
|
val cosLatBot = cos(latBotRad)
|
||||||
|
|
||||||
// We scan column by column. For each column we determine the longitude,
|
// We scan column by column. For each column we determine the longitude,
|
||||||
// then find the latitude range that is in night and shade it.
|
// then find the latitude range that is in night and shade it.
|
||||||
// Since longitude is constant along a vertical strip and the day/night
|
// Since longitude is constant along a vertical strip and the day/night
|
||||||
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
var x = 0
|
var x = 0
|
||||||
while (x < w) {
|
while (x < w) {
|
||||||
// Get the geographic coordinate at the top and bottom of this column.
|
val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
|
||||||
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
|
||||||
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
|
||||||
|
|
||||||
val lonRad = Math.toRadians(geoTop.longitude)
|
val lonRad = Math.toRadians(geoTop.longitude)
|
||||||
val cosLonDiff = cos(lonRad - sunLonRad)
|
val cosLonDiff = cos(lonRad - sunLonRad)
|
||||||
|
|
||||||
// Top pixel geographic lat
|
|
||||||
val latTopRad = Math.toRadians(geoTop.latitude)
|
|
||||||
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
|
||||||
val latBotRad = Math.toRadians(geoBot.latitude)
|
|
||||||
|
|
||||||
// dot(sunVec, pointVec) < 0 → night
|
// dot(sunVec, pointVec) < 0 → night
|
||||||
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
||||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
|
||||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
|
||||||
|
|
||||||
when {
|
when {
|
||||||
dotTop < 0 && dotBot < 0 -> {
|
dotTop < 0 && dotBot < 0 -> {
|
||||||
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
|
|||||||
var hi = yBot
|
var hi = yBot
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
val mid = (lo + hi) / 2
|
val mid = (lo + hi) / 2
|
||||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
|
||||||
val latRad = Math.toRadians(geo.latitude)
|
val latRad = Math.toRadians(geo.latitude)
|
||||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||||
if (dot < 0) hi = mid else lo = mid
|
if (dot < 0) hi = mid else lo = mid
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
@@ -71,6 +70,10 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
|||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import org.osmdroid.events.DelayedMapListener
|
||||||
|
import org.osmdroid.events.MapListener
|
||||||
|
import org.osmdroid.events.ScrollEvent
|
||||||
|
import org.osmdroid.events.ZoomEvent
|
||||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.CustomZoomButtonsController
|
import org.osmdroid.views.CustomZoomButtonsController
|
||||||
@@ -126,6 +129,21 @@ fun MapDestination() {
|
|||||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val mapView = rememberMapViewWithLifecycle()
|
val mapView = rememberMapViewWithLifecycle()
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
DisposableEffect(lifecycle) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
|
||||||
|
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycle.removeObserver(observer)
|
||||||
|
viewModel.onAction(MapAction.SetVisible(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
MapScreen(uiState, viewModel::onAction, mapView)
|
MapScreen(uiState, viewModel::onAction, mapView)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,19 +301,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
|||||||
private val markerPool = HashMap<String, Marker>()
|
private val markerPool = HashMap<String, Marker>()
|
||||||
private var lastMapView: MapView? = null
|
private var lastMapView: MapView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this many satellites inside the viewport labels are dropped in favor of a single
|
||||||
|
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
|
||||||
|
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
|
||||||
|
*/
|
||||||
|
private const val LABEL_LIMIT = 128
|
||||||
|
|
||||||
|
/** Degrees of space around the viewport so markers don't pop in at the edges */
|
||||||
|
private const val VIEWPORT_MARGIN = 8.0
|
||||||
|
|
||||||
|
/** Debounce for viewport-driven marker refreshes, in milliseconds */
|
||||||
|
private const val MAP_LISTENER_DELAY = 128L
|
||||||
|
|
||||||
|
/** Shared dot icon used when too many satellites are visible to label them */
|
||||||
|
private var dotIcon: Drawable? = null
|
||||||
|
|
||||||
|
/** Scratch list reused every frame to avoid per-tick allocation */
|
||||||
|
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
|
||||||
|
|
||||||
|
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
|
||||||
|
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
|
||||||
|
private var lastAction: ((OrbitalObject) -> Unit)? = null
|
||||||
|
|
||||||
private fun setPositions(
|
private fun setPositions(
|
||||||
posMap: Map<OrbitalObject, GeoPos>,
|
posMap: Map<OrbitalObject, GeoPos>,
|
||||||
mapView: MapView,
|
mapView: MapView,
|
||||||
action: (OrbitalObject) -> Unit
|
action: (OrbitalObject) -> Unit
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
lastPositions = posMap
|
||||||
|
lastAction = action
|
||||||
// Clear caches when the MapView instance changes (e.g. config change)
|
// Clear caches when the MapView instance changes (e.g. config change)
|
||||||
if (lastMapView !== mapView) {
|
if (lastMapView !== mapView) {
|
||||||
lastMapView = mapView
|
lastMapView = mapView
|
||||||
markerPool.clear()
|
markerPool.clear()
|
||||||
iconCache.evictAll()
|
iconCache.evictAll()
|
||||||
|
dotIcon = null
|
||||||
footprintPolyline = null
|
footprintPolyline = null
|
||||||
footprintPoints = null
|
footprintPoints = null
|
||||||
|
mapView.addMapListener(DelayedMapListener(object : MapListener {
|
||||||
|
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
|
||||||
|
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
|
||||||
|
}, MAP_LISTENER_DELAY))
|
||||||
}
|
}
|
||||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||||
@@ -304,17 +352,48 @@ private fun setPositions(
|
|||||||
}
|
}
|
||||||
folder.items.clear()
|
folder.items.clear()
|
||||||
|
|
||||||
val activeNames = HashSet<String>(posMap.size)
|
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
|
||||||
posMap.forEach { (satellite, geoPos) ->
|
// exactly when marker labels are shown and drawing is most expensive.
|
||||||
|
visibleSats.clear()
|
||||||
|
if (mapView.width > 0 && mapView.height > 0) {
|
||||||
|
val box = mapView.boundingBox
|
||||||
|
val latNorth = box.latNorth + VIEWPORT_MARGIN
|
||||||
|
val latSouth = box.latSouth - VIEWPORT_MARGIN
|
||||||
|
val lonWest = box.lonWest - VIEWPORT_MARGIN
|
||||||
|
val lonEast = box.lonEast + VIEWPORT_MARGIN
|
||||||
|
val wrapsDateLine = box.lonWest > box.lonEast
|
||||||
|
for ((satellite, geoPos) in posMap) {
|
||||||
|
val lat = geoPos.latitude
|
||||||
|
if (lat !in latSouth..latNorth) continue
|
||||||
|
val lon = geoPos.longitude
|
||||||
|
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
|
||||||
|
else lon in lonWest..lonEast
|
||||||
|
if (isLonVisible) visibleSats.add(satellite to geoPos)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (entry in posMap) visibleSats.add(entry.key to entry.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showLabels = visibleSats.size <= LABEL_LIMIT
|
||||||
|
val activeNames = HashSet<String>(visibleSats.size)
|
||||||
|
for ((satellite, geoPos) in visibleSats) {
|
||||||
val name = satellite.data.name
|
val name = satellite.data.name
|
||||||
activeNames.add(name)
|
activeNames.add(name)
|
||||||
val marker = markerPool.getOrPut(name) {
|
val marker = markerPool.getOrPut(name) {
|
||||||
Marker(mapView).apply {
|
Marker(mapView).apply {
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
icon = getCachedTextIcon(name, mapView)
|
// Resolve the satellite via relatedObject so the listener is allocated
|
||||||
|
// once per marker instead of once per satellite per tick
|
||||||
|
setOnMarkerClickListener { clicked, _ ->
|
||||||
|
(clicked.relatedObject as? OrbitalObject)?.let(action)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
marker.relatedObject = satellite
|
||||||
|
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
|
||||||
|
if (marker.icon !== icon) marker.icon = icon
|
||||||
// Update position in-place — reuse existing GeoPoint if available
|
// Update position in-place — reuse existing GeoPoint if available
|
||||||
val pos = marker.position
|
val pos = marker.position
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
@@ -323,22 +402,32 @@ private fun setPositions(
|
|||||||
} else {
|
} else {
|
||||||
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||||
}
|
}
|
||||||
marker.setOnMarkerClickListener { _, _ ->
|
|
||||||
action(satellite)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
folder.add(marker)
|
folder.add(marker)
|
||||||
}
|
}
|
||||||
// Evict markers for satellites no longer tracked
|
// Evict markers that are no longer tracked or no longer visible
|
||||||
val iter = markerPool.keys.iterator()
|
markerPool.keys.retainAll(activeNames)
|
||||||
while (iter.hasNext()) {
|
visibleSats.clear()
|
||||||
if (iter.next() !in activeNames) iter.remove()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println(e)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-applies the last known positions against the new viewport after a pan or zoom */
|
||||||
|
private fun refreshPositions(mapView: MapView): Boolean {
|
||||||
|
val posMap = lastPositions ?: return false
|
||||||
|
val action = lastAction ?: return false
|
||||||
|
setPositions(posMap, mapView, action)
|
||||||
|
mapView.invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
|
||||||
|
val size = 20
|
||||||
|
val bitmap = createBitmap(size, size)
|
||||||
|
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
|
||||||
|
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
|
||||||
|
}
|
||||||
|
|
||||||
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||||
iconCache[name]?.let { return it }
|
iconCache[name]?.let { return it }
|
||||||
val labelRect = Rect()
|
val labelRect = Rect()
|
||||||
@@ -376,9 +465,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
|||||||
private var footprintPolyline: Polyline? = null
|
private var footprintPolyline: Polyline? = null
|
||||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
private var footprintPoints: ArrayList<GeoPoint>? = null
|
||||||
|
|
||||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
val rangeCircle = orbitalPos.getRangeCircle()
|
|
||||||
var pts = footprintPoints
|
var pts = footprintPoints
|
||||||
if (pts == null || pts.size != rangeCircle.size) {
|
if (pts == null || pts.size != rangeCircle.size) {
|
||||||
pts = ArrayList(rangeCircle.size)
|
pts = ArrayList(rangeCircle.size)
|
||||||
@@ -508,9 +596,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
|||||||
lifecycle.addObserver(lifecycleObserver)
|
lifecycle.addObserver(lifecycleObserver)
|
||||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||||
}
|
}
|
||||||
|
// The overlay caches below are file-level (shared across MapView instances), so they must be
|
||||||
|
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
|
||||||
|
DisposableEffect(mapView) {
|
||||||
|
onDispose {
|
||||||
|
clearMapCaches()
|
||||||
|
mapView.onDetach()
|
||||||
|
}
|
||||||
|
}
|
||||||
return mapView
|
return mapView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearMapCaches() {
|
||||||
|
markerPool.clear()
|
||||||
|
iconCache.evictAll()
|
||||||
|
dotIcon = null
|
||||||
|
footprintPolyline = null
|
||||||
|
footprintPoints = null
|
||||||
|
lastPositions = null
|
||||||
|
lastAction = null
|
||||||
|
lastMapView = null
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||||
LifecycleEventObserver { _, event ->
|
LifecycleEventObserver { _, event ->
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.feature.map
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|
||||||
|
|
||||||
data class MapState(
|
data class MapState(
|
||||||
val mapData: MapData? = null,
|
val mapData: MapData? = null,
|
||||||
@@ -29,7 +28,7 @@ data class MapState(
|
|||||||
val stationPosition: GeoPos? = null,
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>? = null,
|
val track: List<List<GeoPos>>? = null,
|
||||||
val footprint: OrbitalPos? = null,
|
val footprint: List<GeoPos>? = null,
|
||||||
val positions: Map<OrbitalObject, GeoPos>? = null,
|
val positions: Map<OrbitalObject, GeoPos>? = null,
|
||||||
val sunLatDeg: Double = 0.0,
|
val sunLatDeg: Double = 0.0,
|
||||||
val sunLonDeg: Double = 0.0,
|
val sunLonDeg: Double = 0.0,
|
||||||
@@ -42,6 +41,7 @@ sealed interface MapAction {
|
|||||||
data object SelectNext : MapAction
|
data object SelectNext : MapAction
|
||||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||||
|
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MapData(
|
data class MapData(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class MapViewModel(
|
class MapViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -69,6 +72,9 @@ class MapViewModel(
|
|||||||
private var dataUpdateJob: Job? = null
|
private var dataUpdateJob: Job? = null
|
||||||
private var dataUpdateRate = 1000L
|
private var dataUpdateRate = 1000L
|
||||||
private var selectedOrbitalObject: OrbitalObject? = null
|
private var selectedOrbitalObject: OrbitalObject? = null
|
||||||
|
|
||||||
|
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
|
||||||
|
private val isScreenVisible = MutableStateFlow(true)
|
||||||
val uiState: StateFlow<MapState> = _uiState
|
val uiState: StateFlow<MapState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -87,6 +93,7 @@ class MapViewModel(
|
|||||||
MapAction.SelectNext -> scrollSelection(false)
|
MapAction.SelectNext -> scrollSelection(false)
|
||||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||||
|
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +131,11 @@ class MapViewModel(
|
|||||||
selectedOrbitalObject = orbitalObject
|
selectedOrbitalObject = orbitalObject
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataUpdateJob?.cancelAndJoin()
|
dataUpdateJob?.cancelAndJoin()
|
||||||
dataUpdateJob = launch {
|
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
|
||||||
|
// satelliteRepo call internally hops to Default and resumes back on the caller's
|
||||||
|
// dispatcher. On Main that posts one continuation per satellite per tick, which
|
||||||
|
// floods the looper and ANRs for users tracking thousands of objects.
|
||||||
|
dataUpdateJob = launch(Dispatchers.Default) {
|
||||||
val dateNow = Date()
|
val dateNow = Date()
|
||||||
getStationPosition()
|
getStationPosition()
|
||||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||||
@@ -135,9 +146,11 @@ class MapViewModel(
|
|||||||
else -> updateFreq
|
else -> updateFreq
|
||||||
}
|
}
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
// Suspends while the map is off-screen instead of predicting into the void
|
||||||
|
isScreenVisible.first { it }
|
||||||
dateNow.time = System.currentTimeMillis()
|
dateNow.time = System.currentTimeMillis()
|
||||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||||
delay(effectiveRate)
|
delay(effectiveRate.milliseconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +200,8 @@ class MapViewModel(
|
|||||||
|
|
||||||
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
||||||
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
||||||
val footprint = satPos
|
// Range circle is 721 trig-heavy points — keep it on this background dispatcher
|
||||||
|
val footprint = satPos.getRangeCircle()
|
||||||
val mapData = buildMapData(selected, satPos, date)
|
val mapData = buildMapData(selected, satPos, date)
|
||||||
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
||||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
||||||
@@ -294,7 +308,7 @@ class MapViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Number of parallel chunks for satellite position computation */
|
/** Number of parallel chunks for satellite position computation */
|
||||||
private const val PARALLEL_CHUNKS = 4
|
private val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
|
|||||||
@@ -58,20 +58,13 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val allModes = listOf(
|
|
||||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
|
||||||
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
|
|
||||||
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
|
|
||||||
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
|
|
||||||
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
|
|
||||||
)
|
|
||||||
|
|
||||||
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
||||||
private const val dayMinutes = 24 * 60
|
private const val dayMinutes = 24 * 60
|
||||||
private const val minuteStep = 15
|
private const val minuteStep = 15
|
||||||
@@ -147,7 +140,7 @@ internal fun PassesFilterDialog(
|
|||||||
)
|
)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_elev),
|
title = stringResource(R.string.pass_filter_elev),
|
||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
@@ -372,7 +365,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
|||||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
||||||
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(240.dp),
|
columns = GridCells.Adaptive(240.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -381,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allModes) { index, item ->
|
itemsIndexed(Sources.satelliteModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
|
|||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
@@ -135,26 +135,14 @@ private fun PassesScreen(
|
|||||||
}
|
}
|
||||||
if (uiState.shouldSeeWhatsNew) {
|
if (uiState.shouldSeeWhatsNew) {
|
||||||
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
||||||
SharedDialog(
|
WhatsNewDialog(onDismiss = dismiss)
|
||||||
title = stringResource(R.string.pass_whatsnew_title),
|
|
||||||
onDismissRequest = dismiss,
|
|
||||||
onAccept = dismiss,
|
|
||||||
titleFontSize = 18
|
|
||||||
) { padding ->
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.pass_whatsnew_message),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(horizontal = padding)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVerticalLayout,
|
isVerticalLayout = isVerticalLayout,
|
||||||
startAction = {
|
startAction = {
|
||||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||||
@@ -163,7 +151,7 @@ private fun PassesScreen(
|
|||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeepSpacePassPreview() {
|
private fun DeepSpacePassPreview() {
|
||||||
@@ -322,7 +315,7 @@ private fun PassItem(
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||||
|
|||||||
+79
-49
@@ -34,6 +34,9 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -41,6 +44,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class PassesViewModel(
|
class PassesViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -60,7 +64,7 @@ class PassesViewModel(
|
|||||||
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
||||||
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
||||||
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
||||||
modes = settingsRepo.passesSettings.value.selectedModes,
|
modes = settingsRepo.selectedSatModes.value,
|
||||||
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -86,33 +90,43 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Main tick loop: reacts to new passes, then ticks every second
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
satelliteRepo.passes.collectLatest { allPasses ->
|
settingsRepo.selectedSatModes.collectLatest { modes ->
|
||||||
while (isActive) {
|
_uiState.update { it.copy(modes = modes) }
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val isUtc = _uiState.value.isUtc
|
|
||||||
val showDeepSpace = _uiState.value.showDeepSpace
|
|
||||||
val filtered = allPasses
|
|
||||||
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
|
||||||
val processed = computePassProgress(filtered, timeNow)
|
|
||||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
|
||||||
val sunTimes = computeSunTimes(processed, isUtc)
|
|
||||||
val grouped = groupPasses(processed, isUtc)
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
itemsList = processed,
|
|
||||||
groupedPasses = grouped,
|
|
||||||
sunTimes = sunTimes,
|
|
||||||
nextPass = nextPass,
|
|
||||||
nextTime = nextTime,
|
|
||||||
isNextTimeAos = isAos
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
||||||
|
// computations run once per restart, progress/countdown are calculated every second.
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
satelliteRepo.passes,
|
||||||
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
||||||
|
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
||||||
|
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
||||||
|
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
||||||
|
val filtered = if (showDeepSpace) allPasses
|
||||||
|
else allPasses.filter { !it.isDeepSpace }
|
||||||
|
// Expensive: recompute sun times once per items/UTC change, not every second
|
||||||
|
val sunTimes = computeSunTimes(filtered, isUtc)
|
||||||
|
_uiState.update { it.copy(sunTimes = sunTimes) }
|
||||||
|
while (isActive) {
|
||||||
|
val timeNow = System.currentTimeMillis()
|
||||||
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
|
val grouped = groupPasses(processed, isUtc)
|
||||||
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
itemsList = processed,
|
||||||
|
groupedPasses = grouped,
|
||||||
|
nextPass = nextPass,
|
||||||
|
nextTime = nextTime,
|
||||||
|
isNextTimeAos = isAos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: PassesAction) {
|
fun onAction(action: PassesAction) {
|
||||||
@@ -127,21 +141,9 @@ class PassesViewModel(
|
|||||||
aosStartMinute = action.aosStartMinute,
|
aosStartMinute = action.aosStartMinute,
|
||||||
aosEndMinute = action.aosEndMinute,
|
aosEndMinute = action.aosEndMinute,
|
||||||
invertAosTimeWindow = action.invertAosTimeWindow,
|
invertAosTimeWindow = action.invertAosTimeWindow,
|
||||||
showDeepSpace = action.showDeepSpace,
|
showDeepSpace = action.showDeepSpace
|
||||||
modes = _uiState.value.modes
|
|
||||||
)
|
|
||||||
is PassesAction.FilterRadios ->
|
|
||||||
applyFilter(
|
|
||||||
hoursAhead = _uiState.value.hours,
|
|
||||||
minElevation = _uiState.value.elevation,
|
|
||||||
lowElevation = _uiState.value.lowElevation,
|
|
||||||
highElevation = _uiState.value.highElevation,
|
|
||||||
aosStartMinute = _uiState.value.aosStartMinute,
|
|
||||||
aosEndMinute = _uiState.value.aosEndMinute,
|
|
||||||
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
|
||||||
showDeepSpace = _uiState.value.showDeepSpace,
|
|
||||||
modes = action.modes
|
|
||||||
)
|
)
|
||||||
|
is PassesAction.FilterRadios -> setModesFilter(action.modes)
|
||||||
PassesAction.RefreshPasses -> refreshPasses()
|
PassesAction.RefreshPasses -> refreshPasses()
|
||||||
PassesAction.TogglePassesDialog ->
|
PassesAction.TogglePassesDialog ->
|
||||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
@@ -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
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = dateFormat(tz)
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
@@ -180,7 +198,7 @@ class PassesViewModel(
|
|||||||
|
|
||||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = dateFormat(tz)
|
||||||
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
val deepSpace = passes.filter { it.isDeepSpace }
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
@@ -235,8 +253,7 @@ class PassesViewModel(
|
|||||||
aosStartMinute: Int,
|
aosStartMinute: Int,
|
||||||
aosEndMinute: Int,
|
aosEndMinute: Int,
|
||||||
invertAosTimeWindow: Boolean,
|
invertAosTimeWindow: Boolean,
|
||||||
showDeepSpace: Boolean,
|
showDeepSpace: Boolean
|
||||||
modes: List<String>
|
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
settingsRepo.setPassesSettings(
|
settingsRepo.setPassesSettings(
|
||||||
PassesSettings(
|
PassesSettings(
|
||||||
@@ -245,8 +262,7 @@ class PassesViewModel(
|
|||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow,
|
invertAosTimeWindow
|
||||||
modes
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
||||||
@@ -259,10 +275,10 @@ class PassesViewModel(
|
|||||||
aosStartMinute = aosStartMinute,
|
aosStartMinute = aosStartMinute,
|
||||||
aosEndMinute = aosEndMinute,
|
aosEndMinute = aosEndMinute,
|
||||||
invertAosTimeWindow = invertAosTimeWindow,
|
invertAosTimeWindow = invertAosTimeWindow,
|
||||||
showDeepSpace = showDeepSpace,
|
showDeepSpace = showDeepSpace
|
||||||
modes = modes
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val modes = settingsRepo.selectedSatModes.value
|
||||||
satelliteRepo.calculatePasses(
|
satelliteRepo.calculatePasses(
|
||||||
time = System.currentTimeMillis(),
|
time = System.currentTimeMillis(),
|
||||||
hoursAhead = hoursAhead,
|
hoursAhead = hoursAhead,
|
||||||
@@ -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 {
|
private fun refreshPasses() = viewModelScope.launch {
|
||||||
val settings = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
satelliteRepo.calculatePasses(
|
satelliteRepo.calculatePasses(
|
||||||
@@ -283,7 +313,7 @@ class PassesViewModel(
|
|||||||
aosStartMinute = settings.aosStartMinute,
|
aosStartMinute = settings.aosStartMinute,
|
||||||
aosEndMinute = settings.aosEndMinute,
|
aosEndMinute = settings.aosEndMinute,
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
modes = settings.selectedModes
|
modes = settingsRepo.selectedSatModes.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
@@ -45,6 +48,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -59,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
@@ -74,6 +79,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
|
Calculator("Calculator"),
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +118,7 @@ private fun RadarScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutPadding()
|
.layoutPadding()
|
||||||
|
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
|
||||||
.keepScreenOn(),
|
.keepScreenOn(),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
@@ -150,16 +157,31 @@ private fun PagerCard(
|
|||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val pages = RadarPage.entries
|
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||||
|
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val pages = remember(hasCalculatorPage) {
|
||||||
|
buildList {
|
||||||
|
add(RadarPage.Transceivers)
|
||||||
|
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||||
|
add(RadarPage.Sstv)
|
||||||
|
}
|
||||||
|
}
|
||||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(pages.size) {
|
||||||
|
val lastPage = pages.lastIndex
|
||||||
|
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
|
||||||
|
}
|
||||||
|
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||||
|
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = selectedTabIndex == index,
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
)
|
)
|
||||||
@@ -176,6 +198,13 @@ private fun PagerCard(
|
|||||||
radioControl = uiState.radioControl,
|
radioControl = uiState.radioControl,
|
||||||
onAction = onAction
|
onAction = onAction
|
||||||
)
|
)
|
||||||
|
RadarPage.Calculator -> CalculatorPage(
|
||||||
|
transceivers = uiState.transceivers.transmitters,
|
||||||
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
|
orbitalPos = uiState.orbitalPos,
|
||||||
|
onAction = onAction,
|
||||||
|
calculatorOffsetKHz = uiState.calculatorOffsetKHz
|
||||||
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
@@ -225,6 +254,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
|||||||
azimElev = uiState.orientationValues,
|
azimElev = uiState.orientationValues,
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
shouldShowSweep = uiState.shouldShowSweep,
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
shouldUseCompass = uiState.shouldUseCompass,
|
||||||
|
shouldFlipRadar = uiState.shouldFlipRadar,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
sunPosition = uiState.sunPosition,
|
sunPosition = uiState.sunPosition,
|
||||||
moonPosition = uiState.moonPosition,
|
moonPosition = uiState.moonPosition,
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ data class RadarState(
|
|||||||
val satTrack: List<OrbitalPos> = emptyList(),
|
val satTrack: List<OrbitalPos> = emptyList(),
|
||||||
val shouldShowSweep: Boolean = false,
|
val shouldShowSweep: Boolean = false,
|
||||||
val shouldUseCompass: Boolean = false,
|
val shouldUseCompass: Boolean = false,
|
||||||
|
val shouldFlipRadar: Boolean = false,
|
||||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
val transceivers: TransceiverSubState = TransceiverSubState(),
|
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||||
val sstv: SstvSubState = SstvSubState()
|
val sstv: SstvSubState = SstvSubState(),
|
||||||
|
val calculatorOffsetKHz: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class SstvStatus { Idle, Recording }
|
enum class SstvStatus { Idle, Recording }
|
||||||
@@ -96,4 +98,7 @@ sealed interface RadarAction {
|
|||||||
data object SstvReset : RadarAction
|
data object SstvReset : RadarAction
|
||||||
data class SstvSelectMode(val modeName: String) : RadarAction
|
data class SstvSelectMode(val modeName: String) : RadarAction
|
||||||
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
||||||
|
|
||||||
|
// Calculator actions
|
||||||
|
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,7 @@ fun RadarViewCompose(
|
|||||||
azimElev: Pair<Float, Float>,
|
azimElev: Pair<Float, Float>,
|
||||||
shouldShowSweep: Boolean,
|
shouldShowSweep: Boolean,
|
||||||
shouldUseCompass: Boolean,
|
shouldUseCompass: Boolean,
|
||||||
|
shouldFlipRadar: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sunPosition: CelestialComputer.SunPosition? = null,
|
sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
moonPosition: CelestialComputer.MoonPosition? = null,
|
moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
@@ -125,7 +126,9 @@ fun RadarViewCompose(
|
|||||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedSweepColor = primaryColor
|
cachedSweepColor = primaryColor
|
||||||
}
|
}
|
||||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
|
||||||
|
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
|
||||||
|
rotate(rotation) {
|
||||||
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||||
drawRadar(radius, radarColor)
|
drawRadar(radius, radarColor)
|
||||||
drawElevationLabels(radius, primaryColor, measurer)
|
drawElevationLabels(radius, primaryColor, measurer)
|
||||||
|
|||||||
@@ -65,11 +65,14 @@ class RadarViewModel(
|
|||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
private var stationPos = settingsRepo.stationPosition.value
|
||||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||||
|
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
|
||||||
|
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
|
||||||
private var transponders: List<SatRadio> = emptyList()
|
private var transponders: List<SatRadio> = emptyList()
|
||||||
private var sstvDecoder: SstvDecoder? = null
|
private var sstvDecoder: SstvDecoder? = null
|
||||||
private var sstvRecordingJob: Job? = null
|
private var sstvRecordingJob: Job? = null
|
||||||
|
private var sensorCollectionJob: Job? = null
|
||||||
|
|
||||||
// Celestial positions change slowly, recompute at most once per minute
|
// Celestial positions change slowly, recompute at most once per minute
|
||||||
private var lastCelestialUpdateMs = 0L
|
private var lastCelestialUpdateMs = 0L
|
||||||
@@ -88,33 +91,67 @@ class RadarViewModel(
|
|||||||
val uiState: StateFlow<RadarState> = _uiState
|
val uiState: StateFlow<RadarState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectCompassSensor()
|
collectSettingsChanges() // also handles initial sensor subscription
|
||||||
collectSettingsChanges()
|
collectStationPositionChanges()
|
||||||
collectPassAndStartTickLoop()
|
collectPassAndStartTickLoop()
|
||||||
collectRadioTrackingState()
|
collectRadioTrackingState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectCompassSensor() {
|
// Starts sensor collection if not already running.
|
||||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
private fun startSensorCollection() {
|
||||||
viewModelScope.launch {
|
if (sensorCollectionJob?.isActive == true) return
|
||||||
|
sensorCollectionJob = viewModelScope.launch {
|
||||||
sensorsRepo.enableSensor()
|
sensorsRepo.enableSensor()
|
||||||
sensorsRepo.sensorData.collect { data ->
|
sensorsRepo.sensorData.collect { data ->
|
||||||
val orientationValues = (data.first + magDeclination) to data.second
|
val orientationValues =
|
||||||
|
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stops sensor collection and disables the hardware sensor.
|
||||||
|
private fun stopSensorCollection() {
|
||||||
|
sensorCollectionJob?.cancel()
|
||||||
|
sensorCollectionJob = null
|
||||||
|
sensorsRepo.disableSensor()
|
||||||
|
}
|
||||||
|
|
||||||
private fun collectSettingsChanges() {
|
private fun collectSettingsChanges() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collect { settings ->
|
||||||
|
compassOffset = settings.radarCompassOffset
|
||||||
|
compassOffsetElev = settings.radarCompassOffsetElev
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isUtc = settings.stateOfUtc,
|
isUtc = settings.stateOfUtc,
|
||||||
shouldShowSweep = settings.stateOfSweep,
|
shouldShowSweep = settings.stateOfSweep,
|
||||||
shouldUseCompass = settings.stateOfSensors
|
shouldUseCompass = settings.stateOfSensors,
|
||||||
|
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Reactively wire sensor hardware to the compass setting
|
||||||
|
when {
|
||||||
|
settings.stateOfSensors -> startSensorCollection()
|
||||||
|
else -> stopSensorCollection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectStationPositionChanges() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.stationPosition.collect { newPos ->
|
||||||
|
if (stationPos != newPos) {
|
||||||
|
stationPos = newPos
|
||||||
|
magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||||
|
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
|
||||||
|
val pass = _uiState.value.currentPass ?: return@collect
|
||||||
|
if (!pass.isDeepSpace) {
|
||||||
|
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||||
|
_uiState.update { it.copy(satTrack = track) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +250,7 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
sensorsRepo.disableSensor()
|
stopSensorCollection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
@@ -223,7 +260,17 @@ class RadarViewModel(
|
|||||||
// Compute toggle state before the update so we don't read post-update value
|
// Compute toggle state before the update so we don't read post-update value
|
||||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||||
val newUuid = if (isTogglingOff) null else action.uuid
|
val newUuid = if (isTogglingOff) null else action.uuid
|
||||||
_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
|
// Only update the tracking service when selecting a different transponder to
|
||||||
// avoid resetting a user-adjusted TX base on re-expand
|
// avoid resetting a user-adjusted TX base on re-expand
|
||||||
if (!isTogglingOff) {
|
if (!isTogglingOff) {
|
||||||
@@ -272,6 +319,15 @@ class RadarViewModel(
|
|||||||
sstvDecoder?.clearPixels()
|
sstvDecoder?.clearPixels()
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||||
}
|
}
|
||||||
|
is RadarAction.ChangeCalculatorOffset -> {
|
||||||
|
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.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
||||||
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
||||||
// - 0.50-0.60: Conservative, best for noisy environments
|
// - 0.50-0.60: Conservative, best for noisy environments
|
||||||
private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default
|
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
|
||||||
|
|
||||||
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
||||||
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
||||||
@@ -422,7 +478,7 @@ class RadarViewModel(
|
|||||||
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
||||||
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
||||||
// Ask users to compare both if they report line-skipping/compression artifacts.
|
// Ask users to compare both if they report line-skipping/compression artifacts.
|
||||||
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
|
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
|
||||||
|
|
||||||
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
||||||
private const val SSTV_INCLUDE_SCOPE = false
|
private const val SSTV_INCLUDE_SCOPE = false
|
||||||
@@ -434,7 +490,6 @@ class RadarViewModel(
|
|||||||
218.1, 225.7, 233.6, 241.8, 250.3
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
|
|||||||
@@ -49,14 +49,15 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -78,12 +79,12 @@ internal fun SstvPage(
|
|||||||
) {
|
) {
|
||||||
Text(text = "🎙️", fontSize = 48.sp)
|
Text(text = "🎙️", fontSize = 48.sp)
|
||||||
Text(
|
Text(
|
||||||
text = "Microphone access needed",
|
text = stringResource(R.string.sstv_mic_permission),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Button(onClick = requestMicPermission) {
|
Button(onClick = requestMicPermission) {
|
||||||
Text("Grant permission")
|
Text(stringResource(R.string.sstv_grant_permission))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,14 +97,14 @@ internal fun SstvPage(
|
|||||||
if (showModeDialog.value) {
|
if (showModeDialog.value) {
|
||||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||||
val dismiss = { showModeDialog.value = false }
|
val dismiss = { showModeDialog.value = false }
|
||||||
SharedDialog(
|
ConfirmDialog(
|
||||||
title = "SSTV Mode",
|
title = stringResource(R.string.sstv_mode_title),
|
||||||
onDismissRequest = dismiss,
|
onAccept = dismiss,
|
||||||
onAccept = dismiss
|
onCancel = dismiss
|
||||||
) { _ ->
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(0.7f)
|
.fillMaxHeight(0.69f)
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
@@ -167,7 +168,7 @@ internal fun SstvPage(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
|
text = if (sstv.status == SstvStatus.Recording) stringResource(R.string.sstv_listening) else stringResource(R.string.sstv_no_signal),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
@@ -196,7 +197,7 @@ internal fun SstvPage(
|
|||||||
) {
|
) {
|
||||||
// Doppler-corrected downlink frequency hint
|
// Doppler-corrected downlink frequency hint
|
||||||
OutlinedText(
|
OutlinedText(
|
||||||
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
|
text = dopplerFrequency?.let { stringResource(R.string.sstv_rx_format, it) } ?: stringResource(R.string.sstv_no_transceiver),
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fillColor = MaterialTheme.colorScheme.primary,
|
fillColor = MaterialTheme.colorScheme.primary,
|
||||||
@@ -227,7 +228,7 @@ internal fun SstvPage(
|
|||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = "Mode: ${sstv.selectedMode}",
|
text = stringResource(R.string.sstv_mode_format, sstv.selectedMode),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|||||||
+457
-12
@@ -21,14 +21,18 @@ import androidx.compose.animation.AnimatedVisibility
|
|||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -39,14 +43,23 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -54,12 +67,17 @@ import androidx.compose.ui.draw.rotate
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
@@ -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
|
@Composable
|
||||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
@@ -172,12 +257,8 @@ private fun TransceiverItem(
|
|||||||
.rotate(if (isExpanded) 270f else 90f)
|
.rotate(if (isExpanded) 270f else 90f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Title with mode
|
|
||||||
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
|
||||||
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
|
||||||
val fullTitle = "$title ($mode)"
|
|
||||||
Text(
|
Text(
|
||||||
text = fullTitle,
|
text = transceiverTitle(radio),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -322,7 +403,7 @@ private fun ExpandedRadioControl(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "TX Base: ",
|
text = stringResource(R.string.radar_tx_base),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -369,16 +450,17 @@ private fun ExpandedRadioControl(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
maxItemsInEachRow = 5,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
val chipModifier = Modifier.width(64.dp)
|
val chipModifier = Modifier.weight(1f)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioControl.ctcssTone == null,
|
selected = radioControl.ctcssTone == null,
|
||||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
text = "Off",
|
text = stringResource(R.string.radar_off),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@@ -410,19 +492,19 @@ private fun ExpandedRadioControl(
|
|||||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.ConnectRadios) },
|
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||||
text = "Connect",
|
text = stringResource(R.string.radar_connect),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.DisconnectRadios) },
|
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||||
text = "Disconnect",
|
text = stringResource(R.string.radar_disconnect),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.ToggleTracking) },
|
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||||
text = if (radioControl.isTracking) "Stop" else "Track",
|
text = if (radioControl.isTracking) stringResource(R.string.radar_stop) else stringResource(R.string.radar_track),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
@Composable
|
||||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||||
val text = frequency?.let {
|
val text = frequency?.let {
|
||||||
@@ -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 =
|
private val FREQ_ADJUSTMENTS =
|
||||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
|
|
||||||
+11
-11
@@ -39,27 +39,27 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MultiTypesDialogPreview() {
|
private fun MultiModesDialogPreview() {
|
||||||
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
|
val modes = listOf("FM", "APT", "SSTV", "BPSK")
|
||||||
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
|
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MultiTypesDialog(
|
internal fun MultiModesDialog(
|
||||||
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val selected = remember { mutableStateOf(types.toSet()) }
|
val selected = remember { mutableStateOf(modes.toSet()) }
|
||||||
val toggle = { type: String ->
|
val toggle = { mode: String ->
|
||||||
selected.value = if (type in selected.value) selected.value - type else selected.value + type
|
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.value.toList()) }
|
val onAccept = { accept(selected.value.toList()) }
|
||||||
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(240.dp),
|
columns = GridCells.Adaptive(240.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -68,7 +68,7 @@ internal fun MultiTypesDialog(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allTypes) { index, item ->
|
itemsIndexed(allModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+70
-24
@@ -62,10 +62,11 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|||||||
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
|
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||||
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
@@ -87,27 +88,27 @@ private fun SatellitesScreen(
|
|||||||
navigateUp: () -> Unit
|
navigateUp: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isDialogShown) {
|
if (uiState.isDialogShown) {
|
||||||
MultiTypesDialog(
|
MultiModesDialog(
|
||||||
allTypes = uiState.typesList,
|
allModes = uiState.modesList,
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
|
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
accept = { onAction(SatellitesAction.SelectTypes(it)) }
|
accept = { onAction(SatellitesAction.SelectModes(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWarning) {
|
if (uiState.shouldSeeWarning) {
|
||||||
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
||||||
SharedDialog(
|
InfoDialog(
|
||||||
title = stringResource(R.string.sat_warning_title),
|
title = stringResource(R.string.sat_warning_title),
|
||||||
onDismissRequest = dismiss,
|
onDismiss = dismiss,
|
||||||
onAccept = dismiss,
|
onAccept = dismiss
|
||||||
titleFontSize = 18
|
) {
|
||||||
) { padding ->
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sat_warning_message),
|
text = stringResource(R.string.sat_warning_message),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(horizontal = padding)
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +123,9 @@ private fun SatellitesScreen(
|
|||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
if (isVerticalLayout()) {
|
if (isVerticalLayout()) {
|
||||||
TopBar {
|
TopBar {
|
||||||
TypeCard(
|
ModeCard(
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(
|
PrimaryIconCard(
|
||||||
@@ -156,9 +157,9 @@ private fun SatellitesScreen(
|
|||||||
resId = R.drawable.ic_done,
|
resId = R.drawable.ic_done,
|
||||||
modifier = Modifier.semantics { contentDescription = primCardCd }
|
modifier = Modifier.semantics { contentDescription = primCardCd }
|
||||||
)
|
)
|
||||||
TypeCard(
|
ModeCard(
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
SearchBar(
|
SearchBar(
|
||||||
@@ -240,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
|
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -251,12 +252,12 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_tags),
|
painter = painterResource(id = R.drawable.ic_radios),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sat_type_hint, typesText),
|
text = stringResource(R.string.sat_type_hint, modesText),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -322,9 +323,54 @@ private fun SatellitesPreview() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
||||||
|
val selectedItems = items.filter { it.isSelected }
|
||||||
|
val availableItems = items.filterNot { it.isSelected }
|
||||||
|
|
||||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
||||||
items(items = items, key = { item -> item.catnum }) { entry ->
|
stickyHeader(key = "sat_summary_header") {
|
||||||
Satellite(entry, onSelected, Modifier.animateItem())
|
SummaryHeader(
|
||||||
|
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
|
||||||
|
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedItems.isNotEmpty()) {
|
||||||
|
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
|
||||||
|
Satellite(entry, onSelected, Modifier.animateItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableItems.isNotEmpty()) {
|
||||||
|
items(items = availableItems, key = { item -> item.catnum }) { entry ->
|
||||||
|
Satellite(entry, onSelected, Modifier.animateItem())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SummaryHeader(selectedText: String, availableText: String) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selectedText,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = availableText,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+4
-4
@@ -24,8 +24,8 @@ data class SatellitesState(
|
|||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val shouldSeeWarning: Boolean = false,
|
val shouldSeeWarning: Boolean = false,
|
||||||
val itemsList: List<SatItem> = emptyList(),
|
val itemsList: List<SatItem> = emptyList(),
|
||||||
val currentTypes: List<String> = emptyList(),
|
val currentModes: List<String> = emptyList(),
|
||||||
val typesList: List<String> = emptyList()
|
val modesList: List<String> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SatellitesAction {
|
sealed interface SatellitesAction {
|
||||||
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
|
|||||||
data class SearchFor(val query: String) : SatellitesAction
|
data class SearchFor(val query: String) : SatellitesAction
|
||||||
data object SelectAll : SatellitesAction
|
data object SelectAll : SatellitesAction
|
||||||
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
||||||
data class SelectTypes(val types: List<String>) : SatellitesAction
|
data class SelectModes(val modes: List<String>) : SatellitesAction
|
||||||
data object ToggleTypesDialog : SatellitesAction
|
data object ToggleModesDialog : SatellitesAction
|
||||||
data object UnselectAll : SatellitesAction
|
data object UnselectAll : SatellitesAction
|
||||||
}
|
}
|
||||||
+15
-10
@@ -35,13 +35,13 @@ class SatellitesViewModel(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val defaultTypes = selectionRepo.getCurrentTypes()
|
private val defaultModes = selectionRepo.getCurrentModes()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
SatellitesState(
|
SatellitesState(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
||||||
currentTypes = defaultTypes,
|
currentModes = defaultModes,
|
||||||
typesList = selectionRepo.getTypesList()
|
modesList = selectionRepo.getModesList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val uiState: StateFlow<SatellitesState> = _uiState
|
val uiState: StateFlow<SatellitesState> = _uiState
|
||||||
@@ -49,7 +49,7 @@ class SatellitesViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
selectionRepo.setQuery(String())
|
selectionRepo.setQuery(String())
|
||||||
selectionRepo.setTypes(defaultTypes)
|
selectionRepo.setModes(defaultModes)
|
||||||
selectionRepo.getEntriesFlow().collectLatest { items ->
|
selectionRepo.getEntriesFlow().collectLatest { items ->
|
||||||
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,11 @@ class SatellitesViewModel(
|
|||||||
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.selectedSatModes.collectLatest { modes ->
|
||||||
|
_uiState.update { it.copy(currentModes = modes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: SatellitesAction) {
|
fun onAction(action: SatellitesAction) {
|
||||||
@@ -68,8 +73,8 @@ class SatellitesViewModel(
|
|||||||
is SatellitesAction.SearchFor -> searchFor(action.query)
|
is SatellitesAction.SearchFor -> searchFor(action.query)
|
||||||
SatellitesAction.SelectAll -> selectAll(true)
|
SatellitesAction.SelectAll -> selectAll(true)
|
||||||
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
||||||
is SatellitesAction.SelectTypes -> selectTypes(action.types)
|
is SatellitesAction.SelectModes -> selectModes(action.modes)
|
||||||
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
|
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
|
||||||
SatellitesAction.UnselectAll -> selectAll(false)
|
SatellitesAction.UnselectAll -> selectAll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,12 +91,12 @@ class SatellitesViewModel(
|
|||||||
selectionRepo.setSelection(listOf(id), isTicked.not())
|
selectionRepo.setSelection(listOf(id), isTicked.not())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectTypes(types: List<String>) = viewModelScope.launch {
|
private fun selectModes(modes: List<String>) = viewModelScope.launch {
|
||||||
selectionRepo.setTypes(types)
|
selectionRepo.setModes(modes)
|
||||||
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
|
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleTypesDialog() {
|
private fun toggleModesDialog() {
|
||||||
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+471
-225
@@ -17,41 +17,66 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import android.content.Context
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.DragReorderState
|
||||||
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||||
|
import 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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -65,19 +90,28 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
|
|||||||
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
|
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
|
||||||
val titleText = stringResource(id = R.string.prefs_station_title)
|
val titleText = stringResource(id = R.string.prefs_station_title)
|
||||||
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
||||||
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = latValue.value,
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
onValueChange = { latValue.value = it },
|
modifier = Modifier
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
|
.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
.padding(horizontal = LocalSpacing.current.large)
|
||||||
)
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = lonValue.value,
|
value = latValue.value,
|
||||||
onValueChange = { lonValue.value = it },
|
onValueChange = { latValue.value = it },
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
|
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
singleLine = true,
|
||||||
)
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lonValue.value,
|
||||||
|
onValueChange = { lonValue.value = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,12 +134,18 @@ private fun LocatorDialogPreview() {
|
|||||||
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
||||||
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
||||||
val onAccept = { save(locator.value).also { dismiss() } }
|
val onAccept = { save(locator.value).also { dismiss() } }
|
||||||
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
|
ConfirmDialog(
|
||||||
|
title = stringResource(R.string.prefs_locator_title),
|
||||||
|
onCancel = dismiss,
|
||||||
|
onAccept = onAccept
|
||||||
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = locator.value,
|
value = locator.value,
|
||||||
onValueChange = { locator.value = it },
|
onValueChange = { locator.value = it },
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
|
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = LocalSpacing.current.large),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
@@ -116,11 +156,19 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
|
|||||||
private fun TransceiversDialogPreview() {
|
private fun TransceiversDialogPreview() {
|
||||||
MainTheme {
|
MainTheme {
|
||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
useCustomTle = true,
|
satelliteUrls = listOf(
|
||||||
useCustomTransceivers = true,
|
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
tleUrl = "https://example.com/tle.txt",
|
"amsat.org/tle/current/nasabare.txt"
|
||||||
transceiversUrl = "https://example.com/tx.json",
|
),
|
||||||
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
|
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 = {},
|
onImportTle = {},
|
||||||
onImportTransceivers = {},
|
onImportTransceivers = {},
|
||||||
onDismiss = {},
|
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
|
@Composable
|
||||||
fun DataSourcesDialog(
|
fun DataSourcesDialog(
|
||||||
useCustomTle: Boolean,
|
satelliteUrls: List<String>,
|
||||||
useCustomTransceivers: Boolean,
|
transceiversUrls: List<String>,
|
||||||
tleUrl: String,
|
satelliteEnabled: List<Boolean>,
|
||||||
transceiversUrl: String,
|
transceiversEnabled: List<Boolean>,
|
||||||
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
|
statusCodes: Map<String, Int>,
|
||||||
onImportTle: () -> Unit,
|
onImportTle: () -> Unit,
|
||||||
onImportTransceivers: () -> Unit,
|
onImportTransceivers: () -> Unit,
|
||||||
onDismiss: () -> 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 padding = LocalSpacing.current.large
|
||||||
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
|
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
|
||||||
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
|
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
||||||
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
val satEntries = remember {
|
||||||
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
satelliteUrls.mapIndexed { i, url ->
|
||||||
val onAccept = {
|
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
|
||||||
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
|
}.toMutableStateList()
|
||||||
onDismiss()
|
|
||||||
}
|
}
|
||||||
val onCancel = { onDismiss() }
|
val txEntries = remember {
|
||||||
SharedDialog(
|
transceiversUrls.mapIndexed { i, url ->
|
||||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true })
|
||||||
onCancel = onCancel,
|
}.toMutableStateList()
|
||||||
onAccept = onAccept,
|
}
|
||||||
) {
|
val listState = rememberLazyListState()
|
||||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
val dragState = rememberDragReorderState(listState)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
val onRestoreSatDefaults = {
|
||||||
CardButton(
|
satEntries.clear()
|
||||||
onClick = {
|
satEntries.addAll(Sources.satelliteDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
|
||||||
onImportTle()
|
Unit
|
||||||
onDismiss()
|
}
|
||||||
},
|
val onRestoreTxDefaults = {
|
||||||
text = "TLE/3LE (.txt)\nOMM (.csv)",
|
txEntries.clear()
|
||||||
modifier = Modifier.weight(1f)
|
txEntries.addAll(Sources.transceiversDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
|
||||||
)
|
Unit
|
||||||
CardButton(
|
}
|
||||||
onClick = {
|
val onAccept = {
|
||||||
onImportTransceivers()
|
val satFiltered = satEntries.filter { it.url.isNotBlank() }
|
||||||
onDismiss()
|
val txFiltered = txEntries.filter { it.url.isNotBlank() }
|
||||||
},
|
try {
|
||||||
text = "Transceivers\nSatNOGS (.json)",
|
onSave(
|
||||||
modifier = Modifier.weight(1f)
|
satFiltered.map { it.url },
|
||||||
)
|
txFiltered.map { it.url },
|
||||||
}
|
satFiltered.map { it.enabled },
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
txFiltered.map { it.enabled }
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
} finally {
|
||||||
Row(
|
onDismiss()
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
@Preview(showBackground = true)
|
||||||
@@ -252,6 +441,7 @@ fun PreviewNetworkOutputDialog() {
|
|||||||
frequencyAddress = "127.0.0.1",
|
frequencyAddress = "127.0.0.1",
|
||||||
frequencyPort = "4532",
|
frequencyPort = "4532",
|
||||||
frequencyFormat = $$"F $FREQ",
|
frequencyFormat = $$"F $FREQ",
|
||||||
|
frequencyOffsetHz = 0L,
|
||||||
bluetoothRotatorState = false,
|
bluetoothRotatorState = false,
|
||||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = "Default",
|
bluetoothRotatorName = "Default",
|
||||||
@@ -261,7 +451,7 @@ fun PreviewNetworkOutputDialog() {
|
|||||||
bluetoothFrequencyFormat = $$"F $FREQ"
|
bluetoothFrequencyFormat = $$"F $FREQ"
|
||||||
),
|
),
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
onSave = { _, _, _, _, _, _, _, _ -> }
|
onSave = { _, _, _, _, _, _, _, _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,7 +462,7 @@ fun NetworkOutputDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (
|
onSave: (
|
||||||
Boolean, String, String, String,
|
Boolean, String, String, String,
|
||||||
Boolean, String, String, String
|
Boolean, String, String, String, Long
|
||||||
) -> Unit
|
) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
@@ -286,16 +476,19 @@ fun NetworkOutputDialog(
|
|||||||
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
|
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
|
||||||
}
|
}
|
||||||
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
|
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
|
||||||
|
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
|
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
|
||||||
val (freqIp, freqPort) = splitAddress(frequencyAddress.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(
|
onSave(
|
||||||
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
|
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
|
||||||
frequencyState.value, freqIp, freqPort, frequencyFormat.value
|
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
SharedDialog(
|
ConfirmDialog(
|
||||||
title = stringResource(R.string.prefs_net_title),
|
title = stringResource(R.string.prefs_net_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
@@ -324,6 +517,27 @@ fun NetworkOutputDialog(
|
|||||||
onFormatChange = { frequencyFormat.value = it },
|
onFormatChange = { frequencyFormat.value = it },
|
||||||
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +566,7 @@ fun PreviewBluetoothOutputDialog() {
|
|||||||
frequencyAddress = "127.0.0.1",
|
frequencyAddress = "127.0.0.1",
|
||||||
frequencyPort = "4532",
|
frequencyPort = "4532",
|
||||||
frequencyFormat = $$"F $FREQ",
|
frequencyFormat = $$"F $FREQ",
|
||||||
|
frequencyOffsetHz = 0L,
|
||||||
bluetoothRotatorState = false,
|
bluetoothRotatorState = false,
|
||||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = "Default",
|
bluetoothRotatorName = "Default",
|
||||||
@@ -377,11 +592,15 @@ fun BluetoothOutputDialog(
|
|||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
|
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
|
||||||
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
val rotatorAddress =
|
||||||
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
|
||||||
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
||||||
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
val frequencyState =
|
||||||
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
||||||
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
val frequencyAddress =
|
||||||
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
||||||
|
val frequencyFormat =
|
||||||
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
||||||
@@ -389,7 +608,7 @@ fun BluetoothOutputDialog(
|
|||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
SharedDialog(
|
ConfirmDialog(
|
||||||
title = stringResource(R.string.prefs_bt_title),
|
title = stringResource(R.string.prefs_bt_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
@@ -470,23 +689,22 @@ private fun OutputChannelSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadioControlDialog(
|
fun RadioControlDialog(
|
||||||
initialSettings: RadioControlSettings,
|
initialSettings: RadioControlSettings,
|
||||||
|
pairedBluetoothDevices: List<Pair<String, String>>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (RadioControlSettings) -> Unit
|
onSave: (RadioControlSettings) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val padding = LocalSpacing.current.large
|
||||||
val padding = LocalSpacing.current.large
|
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
|
||||||
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||||
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
||||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||||
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||||
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||||
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
||||||
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
||||||
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||||
|
|
||||||
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
@@ -496,157 +714,157 @@ fun RadioControlDialog(
|
|||||||
if (!isIcom && splitMode.value) splitMode.value = false
|
if (!isIcom && splitMode.value) splitMode.value = false
|
||||||
|
|
||||||
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
||||||
else RadioControlSettings.BAUD_RATES_YAESU
|
else RadioControlSettings.BAUD_RATES_YAESU
|
||||||
|
|
||||||
// If current baud rate is not in the new list, default to the first available
|
// If current baud rate is not in the new list, default to the first available
|
||||||
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
||||||
|
|
||||||
val pairedDevices: List<Pair<String, String>> = remember {
|
|
||||||
buildList {
|
|
||||||
try {
|
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
||||||
manager.adapter?.bondedDevices?.forEach {
|
|
||||||
add(Pair(it.name ?: "Unknown", it.address ?: ""))
|
|
||||||
}
|
|
||||||
} catch (_: SecurityException) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
RadioControlSettings(
|
RadioControlSettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
radioModel = radioModel.value,
|
radioModel = radioModel.value,
|
||||||
txRadioAddress = txAddress.value,
|
txRadioAddress = txAddress.value,
|
||||||
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
||||||
txRadioName = txName.value,
|
txRadioName = txName.value,
|
||||||
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
||||||
baudRate = baudRate.intValue,
|
baudRate = baudRate.intValue,
|
||||||
splitMode = splitMode.value
|
splitMode = splitMode.value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
SharedDialog(
|
ConfirmDialog(
|
||||||
title = stringResource(R.string.rc_settings_title),
|
title = stringResource(R.string.rc_settings_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||||
|
|
||||||
// Enable switch
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.rc_enable_switch))
|
Text(stringResource(R.string.rc_enable_switch))
|
||||||
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Radio model — FlowRow so chips wrap on small screens
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.rc_radio_model),
|
text = stringResource(R.string.rc_radio_model),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioModel.value == model,
|
selected = radioModel.value == model,
|
||||||
onClick = { radioModel.value = model },
|
onClick = { radioModel.value = model },
|
||||||
label = { Text(model, fontSize = 12.sp) },
|
label = {
|
||||||
enabled = enabled.value
|
Text(
|
||||||
|
text = compactRadioModelLabel(model),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = enabled.value,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// IC-705 split-mode toggle (only shown for IC-705)
|
val isSplitModeAvailable = enabled.value && isIcom
|
||||||
if (isIcom) {
|
val splitModeLabelColor = if (isSplitModeAvailable) {
|
||||||
Row(
|
MaterialTheme.colorScheme.onSurface
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
} else {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
|
|
||||||
Text(
|
|
||||||
text = "Use VFO-A/B split on one IC-705 instead of two radios",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = splitMode.value,
|
|
||||||
onCheckedChange = { splitMode.value = it },
|
|
||||||
enabled = enabled.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
// TX Radio (always shown; in split mode this is the single IC-705)
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Text(txLabel, fontWeight = FontWeight.Medium)
|
|
||||||
if (txAddress.value.isNotBlank()) {
|
|
||||||
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
|
||||||
}
|
|
||||||
CardButton(
|
|
||||||
onClick = { selectingFor.value = "tx" },
|
|
||||||
text = if (isSingleRadio) "Select Device" else "Select TX Device",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_split_mode),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = splitModeLabelColor
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = splitMode.value,
|
||||||
|
onCheckedChange = { splitMode.value = it },
|
||||||
|
enabled = isSplitModeAvailable
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// RX Radio (hidden in split mode — the same radio handles both)
|
Text("Radio devices", fontWeight = FontWeight.Medium)
|
||||||
if (!isSingleRadio) {
|
Row(
|
||||||
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
if (rxAddress.value.isNotBlank()) {
|
modifier = Modifier.fillMaxWidth()
|
||||||
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
) {
|
||||||
}
|
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { selectingFor.value = "rx" },
|
onClick = { selectingFor.value = "tx" },
|
||||||
text = "Select RX Device",
|
text = if (isSingleRadio) "Select Radio" else "Select TX",
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
|
||||||
|
text = if (isSingleRadio) "RX = TX" else "Select RX",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
}
|
}
|
||||||
|
if (txAddress.value.isNotBlank()) {
|
||||||
|
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
if (isSingleRadio && txAddress.value.isNotBlank()) {
|
||||||
|
Text("RX: same as TX", fontSize = 13.sp)
|
||||||
|
} else if (rxAddress.value.isNotBlank()) {
|
||||||
|
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Paired device picker (inline, shown while selecting)
|
|
||||||
if (selectingFor.value.isNotBlank()) {
|
if (selectingFor.value.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Paired Bluetooth Devices:",
|
text = stringResource(R.string.settings_paired_devices),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
if (pairedDevices.isEmpty()) {
|
if (pairedBluetoothDevices.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
||||||
fontSize = 13.sp
|
fontSize = 13.sp
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
pairedDevices.forEach { (name, address) ->
|
pairedBluetoothDevices.forEach { (name, address) ->
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
if (selectingFor.value == "tx") {
|
if (selectingFor.value == "tx") {
|
||||||
txAddress.value = address
|
txAddress.value = address
|
||||||
txName.value = name
|
txName.value = name
|
||||||
|
if (isSingleRadio) {
|
||||||
|
rxAddress.value = address
|
||||||
|
rxName.value = name
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
rxAddress.value = address
|
rxAddress.value = address
|
||||||
rxName.value = name
|
rxName.value = name
|
||||||
}
|
}
|
||||||
selectingFor.value = ""
|
selectingFor.value = ""
|
||||||
}
|
}
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(name, modifier = Modifier.weight(1f))
|
Text(name, modifier = Modifier.weight(1f))
|
||||||
@@ -658,15 +876,26 @@ fun RadioControlDialog(
|
|||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baud rate — FlowRow so all chips fit on narrow screens
|
|
||||||
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
maxItemsInEachRow = 6,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
baudRates.forEach { rate ->
|
baudRates.forEach { rate ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = rate == baudRate.intValue,
|
selected = rate == baudRate.intValue,
|
||||||
onClick = { baudRate.intValue = rate },
|
onClick = { baudRate.intValue = rate },
|
||||||
label = { Text(rate.toString(), fontSize = 12.sp) },
|
label = {
|
||||||
enabled = enabled.value
|
Text(
|
||||||
|
text = baudLabel(rate),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = enabled.value,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+130
-86
@@ -25,10 +25,12 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
@@ -38,6 +40,7 @@ import androidx.compose.material3.ElevatedCard
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -61,6 +64,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
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.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -89,22 +94,10 @@ fun SettingsDestination() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||||
val dialogs = rememberDialogVisibility()
|
val dialogs = rememberDialogVisibility()
|
||||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
||||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
||||||
val permissions = rememberSettingsPermissions(
|
val permissions = rememberSettingsPermissions(
|
||||||
sendAction = onAction,
|
sendAction = onAction,
|
||||||
onBluetoothGranted = { dialogs.bluetooth = true },
|
onBluetoothGranted = { dialogs.bluetooth = true },
|
||||||
onNetworkGranted = { dialogs.network = true },
|
onNetworkGranted = { dialogs.network = true }
|
||||||
onCustomSourcesPermissionGranted = {
|
|
||||||
pendingCustomSourcesGrant.value?.invoke()
|
|
||||||
pendingCustomSourcesGrant.value = null
|
|
||||||
pendingCustomSourcesDeny.value = null
|
|
||||||
},
|
|
||||||
onCustomSourcesPermissionDenied = {
|
|
||||||
pendingCustomSourcesDeny.value?.invoke()
|
|
||||||
pendingCustomSourcesGrant.value = null
|
|
||||||
pendingCustomSourcesDeny.value = null
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
@@ -125,30 +118,23 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
if (dialogs.dataSources) {
|
if (dialogs.dataSources) {
|
||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
|
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||||
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||||
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||||
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||||
requestCustomSourcesPermission = { onGranted, onDenied ->
|
statusCodes = uiState.dataSourcesStatus,
|
||||||
pendingCustomSourcesGrant.value = onGranted
|
|
||||||
pendingCustomSourcesDeny.value = onDenied
|
|
||||||
permissions.launchCustomSourcesPermission()
|
|
||||||
},
|
|
||||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||||
onDismiss = { dialogs.dataSources = false },
|
onDismiss = { dialogs.dataSources = false },
|
||||||
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
|
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||||
val current = uiState.dataSourcesSettings
|
val newSettings = DataSourcesSettings(
|
||||||
val newSettings = current.copy(
|
satelliteUrls = satUrls,
|
||||||
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
|
transceiversUrls = txUrls,
|
||||||
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
|
satelliteEnabled = satEnabled,
|
||||||
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
|
transceiversEnabled = txEnabled
|
||||||
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
|
||||||
)
|
)
|
||||||
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
|
onAction(SettingsAction.UpdateFromWeb)
|
||||||
onAction(SettingsAction.UpdateFromWeb)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -156,14 +142,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
NetworkOutputDialog(
|
NetworkOutputDialog(
|
||||||
initialSettings = uiState.rcSettings,
|
initialSettings = uiState.rcSettings,
|
||||||
onDismiss = { dialogs.network = false },
|
onDismiss = { dialogs.network = false },
|
||||||
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
|
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
|
||||||
onAction(
|
onAction(
|
||||||
SettingsAction.UpdateRC(
|
SettingsAction.UpdateRC(
|
||||||
uiState.rcSettings.copy(
|
uiState.rcSettings.copy(
|
||||||
rotatorState = rotState, rotatorAddress = rotAddr,
|
rotatorState = rotState, rotatorAddress = rotAddr,
|
||||||
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
||||||
frequencyState = freqState, frequencyAddress = freqAddr,
|
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) {
|
if (dialogs.radioControl) {
|
||||||
RadioControlDialog(
|
RadioControlDialog(
|
||||||
initialSettings = uiState.radioControlSettings,
|
initialSettings = uiState.radioControlSettings,
|
||||||
|
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
|
||||||
onDismiss = { dialogs.radioControl = false },
|
onDismiss = { dialogs.radioControl = false },
|
||||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (dialogs.whatsNew) {
|
||||||
|
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
|
||||||
|
}
|
||||||
|
|
||||||
// URLs for top bar
|
// URLs for top bar
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val appUrl = stringResource(R.string.prefs_app_url)
|
// val appUrl = stringResource(R.string.prefs_app_url)
|
||||||
val donateUrl = stringResource(R.string.prefs_donate_url)
|
val donateUrl = stringResource(R.string.prefs_donate_url)
|
||||||
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
|
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
|
||||||
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
|
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
|
||||||
@@ -214,7 +205,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
if (isVerticalLayout) {
|
if (isVerticalLayout) {
|
||||||
TopBar {
|
TopBar {
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { safeOpenUri(appUrl) },
|
onClick = { dialogs.whatsNew = true },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -242,7 +233,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { safeOpenUri(appUrl) },
|
onClick = { dialogs.whatsNew = true },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -298,8 +289,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onRadioControlClick = { dialogs.radioControl = true }
|
onRadioControlClick = { dialogs.radioControl = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
item { CardCredits() }
|
if (isVerticalLayout) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OtherCard(uiState.otherSettings, onAction)
|
||||||
|
CardCredits()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
|
||||||
|
) {
|
||||||
|
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,9 +342,9 @@ private fun LocationCard(
|
|||||||
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude))
|
||||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude))
|
||||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
@@ -478,40 +483,83 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
shouldSeeWarning = false,
|
shouldSeeWarning = false,
|
||||||
shouldSeeWhatsNew = false
|
shouldSeeWhatsNew = false
|
||||||
)
|
)
|
||||||
OtherCard(settings = values) {}
|
OtherCard(settings = values, onAction = {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
|
||||||
ElevatedCard(
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(268.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_other_title),
|
text = stringResource(id = R.string.prefs_other_title),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
onAction(SettingsAction.ToggleUtc(it))
|
// Display preferences: UTC clock + night filter
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
||||||
|
onAction(SettingsAction.ToggleUtc(it))
|
||||||
|
}
|
||||||
|
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||||
|
onAction(SettingsAction.ToggleNightMode(it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Radar behavior: sweep animation + sensor control
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||||
|
onAction(SettingsAction.ToggleSweep(it))
|
||||||
|
}
|
||||||
|
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
||||||
|
onAction(SettingsAction.ToggleSensor(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Data management (full width)
|
||||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||||
onAction(SettingsAction.ToggleUpdate(it))
|
onAction(SettingsAction.ToggleUpdate(it))
|
||||||
}
|
}
|
||||||
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
onAction(SettingsAction.ToggleSweep(it))
|
// Compass calibration sliders at the bottom
|
||||||
}
|
CompassOffsetRow(
|
||||||
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
labelResId = R.string.prefs_other_compass_offset,
|
||||||
onAction(SettingsAction.ToggleSensor(it))
|
value = settings.radarCompassOffset,
|
||||||
}
|
range = -180f..180f
|
||||||
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
CompassOffsetRow(
|
||||||
|
labelResId = R.string.prefs_other_compass_offset_elev,
|
||||||
|
value = settings.radarCompassOffsetElev,
|
||||||
|
range = -90f..90f
|
||||||
|
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompassOffsetRow(
|
||||||
|
labelResId: Int,
|
||||||
|
value: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
onValueChange: (Float) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = labelResId))
|
||||||
|
Text(text = "${value.toInt()}°")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
valueRange = range
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -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
|
@Composable
|
||||||
private fun formatUpdateTime(updateTime: Long): String {
|
private fun formatUpdateTime(updateTime: Long): String {
|
||||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||||
@@ -558,7 +621,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
|||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(268.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -592,7 +654,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_satellites),
|
painter = painterResource(id = R.drawable.ic_sputnik),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -643,6 +705,7 @@ private class DialogVisibility {
|
|||||||
var network by mutableStateOf(false)
|
var network by mutableStateOf(false)
|
||||||
var bluetooth by mutableStateOf(false)
|
var bluetooth by mutableStateOf(false)
|
||||||
var radioControl by mutableStateOf(false)
|
var radioControl by mutableStateOf(false)
|
||||||
|
var whatsNew by mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -650,12 +713,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
return rememberSaveable(saver = run {
|
return rememberSaveable(saver = run {
|
||||||
androidx.compose.runtime.saveable.Saver(
|
androidx.compose.runtime.saveable.Saver(
|
||||||
save = {
|
save = {
|
||||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
|
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
|
||||||
},
|
},
|
||||||
restore = {
|
restore = {
|
||||||
DialogVisibility().apply {
|
DialogVisibility().apply {
|
||||||
position = it[0]; locator = it[1]; dataSources = it[2]
|
position = it[0]; locator = it[1]; dataSources = it[2]
|
||||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]
|
network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -672,17 +735,14 @@ private class SettingsPermissions(
|
|||||||
val launchTleImport: () -> Unit,
|
val launchTleImport: () -> Unit,
|
||||||
val launchTransceiverImport: () -> Unit,
|
val launchTransceiverImport: () -> Unit,
|
||||||
val launchBluetooth: () -> Unit,
|
val launchBluetooth: () -> Unit,
|
||||||
val launchNetwork: () -> Unit,
|
val launchNetwork: () -> Unit
|
||||||
val launchCustomSourcesPermission: () -> Unit
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberSettingsPermissions(
|
private fun rememberSettingsPermissions(
|
||||||
sendAction: (SettingsAction) -> Unit,
|
sendAction: (SettingsAction) -> Unit,
|
||||||
onBluetoothGranted: () -> Unit,
|
onBluetoothGranted: () -> Unit,
|
||||||
onNetworkGranted: () -> Unit,
|
onNetworkGranted: () -> Unit
|
||||||
onCustomSourcesPermissionGranted: () -> Unit,
|
|
||||||
onCustomSourcesPermissionDenied: () -> Unit
|
|
||||||
): SettingsPermissions {
|
): SettingsPermissions {
|
||||||
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
||||||
val locationRequest = rememberLauncherForActivityResult(
|
val locationRequest = rememberLauncherForActivityResult(
|
||||||
@@ -719,15 +779,6 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(networkError))
|
else sendAction(SettingsAction.ShowToast(networkError))
|
||||||
}
|
}
|
||||||
|
|
||||||
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
onCustomSourcesPermissionGranted()
|
|
||||||
} else {
|
|
||||||
onCustomSourcesPermissionDenied()
|
|
||||||
sendAction(SettingsAction.ShowToast(networkError))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return remember {
|
return remember {
|
||||||
SettingsPermissions(
|
SettingsPermissions(
|
||||||
launchLocation = {
|
launchLocation = {
|
||||||
@@ -744,13 +795,6 @@ private fun rememberSettingsPermissions(
|
|||||||
} else {
|
} else {
|
||||||
onNetworkGranted()
|
onNetworkGranted()
|
||||||
}
|
}
|
||||||
},
|
|
||||||
launchCustomSourcesPermission = {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
|
||||||
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
|
||||||
} else {
|
|
||||||
onCustomSourcesPermissionGranted()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -41,7 +41,9 @@ data class SettingsState(
|
|||||||
val otherSettings: OtherSettings,
|
val otherSettings: OtherSettings,
|
||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
val radioControlSettings: RadioControlSettings,
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings
|
val dataSourcesSettings: DataSourcesSettings,
|
||||||
|
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||||
|
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -64,6 +66,8 @@ sealed interface SettingsAction {
|
|||||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||||
|
data class SetRadarCompassOffset(val value: Float) : SettingsAction
|
||||||
|
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+12
-2
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
|
private val container: IMainContainer,
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
@@ -47,7 +48,9 @@ class SettingsViewModel(
|
|||||||
otherSettings = settingsRepo.otherSettings.value,
|
otherSettings = settingsRepo.otherSettings.value,
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||||
|
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||||
|
pairedBluetoothDevices = container.providePairedBluetoothDevices()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,6 +93,11 @@ class SettingsViewModel(
|
|||||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.dataSourcesStatus.collect { status ->
|
||||||
|
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.radioControlSettings.collect { settings ->
|
settingsRepo.radioControlSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||||
@@ -121,6 +129,8 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
|
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
||||||
|
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
||||||
// Remote control & data sources
|
// Remote control & data sources
|
||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
@@ -196,7 +206,6 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -204,6 +213,7 @@ class SettingsViewModel(
|
|||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
|
container = container,
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
+291
@@ -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})?$")
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
|
org.gradle.tooling.parallel=true
|
||||||
org.gradle.workers.max=4
|
org.gradle.workers.max=4
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "443"
|
appVersionCode = "447"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.3"
|
appVersionName = "4.4.7"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -12,24 +12,25 @@ jdkVersion = "21"
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.3.1"
|
android-gradle-plugin = "9.4.0"
|
||||||
|
|
||||||
androidx-core-ktx = "1.19.0"
|
androidx-core-ktx = "1.19.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.5"
|
||||||
|
|
||||||
compose-bom = "2026.06.01"
|
compose-bom = "2026.09.00"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.11.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-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "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"
|
other-osmdroid = "6.1.20"
|
||||||
|
|
||||||
test-junit4 = "4.13.2"
|
test-junit4 = "4.13.2"
|
||||||
@@ -72,6 +73,8 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
|
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
|
#noinspection UnusedVersionCatalogEntry
|
||||||
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
|
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#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" }
|
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", 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
|
# plugins defined by this project
|
||||||
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
|
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
|
||||||
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
|
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Sat Jun 27 12:51:12 BST 2026
|
#Sat Sep 12 11:29:59 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
+2
-1
@@ -25,5 +25,6 @@ include(
|
|||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings",
|
||||||
|
":feature:status"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user