mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6fb5eb8f | ||
|
|
f4aef4f4f0 | ||
|
|
9147323db2 | ||
|
|
81d397a09d | ||
|
|
ec1a55c93d | ||
|
|
e5033cb2e9 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# GitHub Copilot Instructions
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then `CLAUDE.md`.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -62,6 +62,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Deploy Bundle to Google Play
|
- name: Deploy Bundle to Google Play
|
||||||
run: |
|
run: |
|
||||||
|
gem install multi_json
|
||||||
gem install fastlane --no-document
|
gem install fastlane --no-document
|
||||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Look4Sat AI Agent Instructions
|
||||||
|
|
||||||
|
This is the canonical project guide for all AI assistants working on Look4Sat.
|
||||||
|
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
|
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||||
|
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||||
|
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||||
|
data download.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
|
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||||
|
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||||
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
|
**Clean Architecture layers:**
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|----------------------|---------------------------------------------------------------------|
|
||||||
|
| `app` | Entry point. Aggregates all modules |
|
||||||
|
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||||
|
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||||
|
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||||
|
| `feature:map` | OSMDroid map with ground tracks |
|
||||||
|
| `feature:passes` | Pass predictions and upcoming events |
|
||||||
|
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
||||||
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
|
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Debug build
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Release build (minified, shrunk resources)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
|
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||||
|
|
||||||
|
## Key Libraries
|
||||||
|
|
||||||
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
|
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||||
|
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||||
|
- **OkHttp** 5.x for TLE downloads
|
||||||
|
- **OSMDroid** for map rendering
|
||||||
|
- **Kotlin Serialization** for navigation args and data parsing
|
||||||
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||||
|
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||||
|
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||||
|
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||||
|
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||||
|
|
||||||
|
## Data Formats & Migration
|
||||||
|
|
||||||
|
**TLE vs. OMM/CSV format:**
|
||||||
|
|
||||||
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||||
|
|
||||||
|
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||||
|
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
||||||
|
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
||||||
|
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
||||||
|
|
||||||
|
**Current implementation:**
|
||||||
|
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||||
|
- TLE data is downloaded from configured sources and stored in Room database
|
||||||
|
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||||
|
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||||
|
|
||||||
|
**Migration path:**
|
||||||
|
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||||
|
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||||
|
transition to OMM/CSV automatically.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||||
|
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||||
|
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
|
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||||
|
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||||
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
|
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||||
|
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||||
@@ -1,110 +1,5 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## Project Overview
|
Read `AGENTS.md` first, then follow the instructions there.
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
|
||||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
|
||||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
|
||||||
data download.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
|
||||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
|
||||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
|
||||||
|
|
||||||
| ------------------------ | --------------------------------------------------------------------- |
|
|
||||||
| Module | Responsibility |
|
|
||||||
|--------------------------|-----------------------------------------------------------------------|
|
|
||||||
| `app` | Entry point. Aggregates all modules |
|
|
||||||
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
|
||||||
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
|
||||||
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
|
||||||
| `feature:map` | OSMDroid map with ground tracks |
|
|
||||||
| `feature:passes` | Pass predictions and upcoming events |
|
|
||||||
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
|
||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
|
||||||
| `feature:settings` | User preferences |
|
|
||||||
| ------------------------ | --------------------------------------------------------------------- |
|
|
||||||
|
|
||||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
|
||||||
|
|
||||||
## Build & Run
|
|
||||||
|
|
||||||
```shell
|
|
||||||
# Debug build
|
|
||||||
./gradlew assembleDebug
|
|
||||||
|
|
||||||
# Release build (minified, shrunk resources)
|
|
||||||
./gradlew assembleRelease
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
./gradlew test
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
|
||||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
|
||||||
|
|
||||||
## Key Libraries
|
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
|
||||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
|
||||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
|
||||||
- **OkHttp** 5.x for TLE downloads
|
|
||||||
- **OSMDroid** for map rendering
|
|
||||||
- **Kotlin Serialization** for navigation args and data parsing
|
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
|
||||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
|
||||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
|
||||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
|
||||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
|
||||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
|
||||||
|
|
||||||
## Data Formats & Migration
|
|
||||||
|
|
||||||
**TLE vs. OMM/CSV format:**
|
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
|
||||||
|
|
||||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
|
||||||
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
|
||||||
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
|
||||||
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
|
||||||
|
|
||||||
**Current implementation:**
|
|
||||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
|
||||||
- TLE data is downloaded from configured sources and stored in Room database
|
|
||||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
|
||||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
|
||||||
|
|
||||||
**Migration path:**
|
|
||||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
|
||||||
to handle this transition without code changes—existing users can continue using TLE files while new sources transition
|
|
||||||
to OMM/CSV automatically.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
|
||||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
|
||||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
|
||||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
|
||||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
|
||||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
|
||||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
|
||||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
|
||||||
@@ -29,6 +29,14 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- <intent-filter android:autoVerify="true">-->
|
||||||
|
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||||
|
<!-- <data android:scheme="https" />-->
|
||||||
|
<!-- <data android:host="github.com" />-->
|
||||||
|
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -32,6 +36,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -39,11 +44,13 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -62,6 +69,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.DeeplinkResolver
|
||||||
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
@@ -72,13 +81,47 @@ import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
|||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen() {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
LaunchedEffect(deeplink) {
|
||||||
|
deeplink?.let {
|
||||||
|
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||||
|
rootBackStack.add(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
|
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||||
|
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||||
|
NavDisplay(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
backStack = rootBackStack,
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { slideInTransition },
|
||||||
|
popTransitionSpec = { slideOutTransition },
|
||||||
|
predictivePopTransitionSpec = { slideOutTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
innerPadding.calculateTopPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { if (backStack.size > 1) backStack.removeAt(backStack.size - 1) }
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
|
||||||
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -139,6 +182,7 @@ fun MainScreen() {
|
|||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination { catNum, aosTime ->
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
|
// navigateToRadar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry<Screen.Radar> {
|
entry<Screen.Radar> {
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
description = "Cleans the build directory"
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,9 @@ package com.rtbishop.look4sat.core.data.injection
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.Sensor
|
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
@@ -120,9 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
return SensorsRepo(manager, displayManager)
|
||||||
return SensorsRepo(manager,sensor,window)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import android.hardware.Sensor
|
|||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.WindowManager
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
|
||||||
|
private const val SMOOTHING_FACTOR = 0.15f
|
||||||
|
private const val SENSOR_RATE_US = 16_000
|
||||||
|
|
||||||
class SensorsRepo(
|
class SensorsRepo(
|
||||||
private val sensorManager: SensorManager,
|
private val sensorManager: SensorManager,
|
||||||
private val sensor: Sensor?,
|
private val displayManager: DisplayManager?
|
||||||
private val windowManager: WindowManager
|
) : ISensorsRepo, SensorEventListener {
|
||||||
) : SensorEventListener, ISensorsRepo {
|
|
||||||
|
|
||||||
private val _orientation = MutableStateFlow(Pair(0f, 0f))
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
|
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
private val rotationMatrix = FloatArray(9)
|
private val rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -45,11 +49,7 @@ class SensorsRepo(
|
|||||||
private var smoothPitch = 0f
|
private var smoothPitch = 0f
|
||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
companion object {
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
private const val SMOOTHING_FACTOR = 0.15f
|
|
||||||
}
|
|
||||||
|
|
||||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -62,7 +62,7 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
hasInitialReading = false
|
||||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
@@ -70,16 +70,11 @@ class SensorsRepo(
|
|||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
if (event.sensor == sensor) updateOrientation(event.values)
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDisplayRotation(): Int {
|
private fun getDisplayRotation(): Int {
|
||||||
return try {
|
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
windowManager.defaultDisplay.rotation
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Surface.ROTATION_0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remapForRotation(rotation: Int) {
|
private fun remapForRotation(rotation: Int) {
|
||||||
@@ -101,30 +96,23 @@ class SensorsRepo(
|
|||||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateOrientation(rotationVector: FloatArray) {
|
private fun handleSensorEvent(event: SensorEvent) {
|
||||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||||
remapForRotation(getDisplayRotation())
|
remapForRotation(getDisplayRotation())
|
||||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
|
||||||
|
|
||||||
if (!hasInitialReading) {
|
if (!hasInitialReading) {
|
||||||
smoothAzimuth = magneticAzimuth
|
smoothAzimuth = azimuth
|
||||||
smoothPitch = pitch
|
smoothPitch = pitch
|
||||||
hasInitialReading = true
|
hasInitialReading = true
|
||||||
} else {
|
} else {
|
||||||
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
|
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||||
smoothPitch = lowPass(smoothPitch, pitch)
|
smoothPitch = lowPass(smoothPitch, pitch)
|
||||||
}
|
}
|
||||||
|
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||||
_orientation.value = Pair(
|
|
||||||
round(smoothAzimuth * 10) / 10,
|
|
||||||
round(smoothPitch * 10) / 10
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard exponential low-pass filter. */
|
|
||||||
private fun lowPass(previous: Float, current: Float): Float {
|
private fun lowPass(previous: Float, current: Float): Float {
|
||||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||||
}
|
}
|
||||||
@@ -135,9 +123,10 @@ class SensorsRepo(
|
|||||||
*/
|
*/
|
||||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||||
var delta = current - previous
|
var delta = current - previous
|
||||||
// Normalise delta into the range (-180, 180]
|
|
||||||
while (delta > 180f) delta -= 360f
|
while (delta > 180f) delta -= 360f
|
||||||
while (delta <= -180f) delta += 360f
|
while (delta <= -180f) delta += 360f
|
||||||
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
|
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||||
}
|
}
|
||||||
@@ -113,8 +113,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedTypes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, null)
|
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
if (typesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return typesString.split(separatorComma)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
look4SatDao.deleteRadios()
|
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -45,7 +46,10 @@ class RemoteSource(
|
|||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
httpClient.newCall(networkRequest).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
ByteArrayInputStream(response.body.bytes())
|
||||||
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
null
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val orientation: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ class SstvDecoder(
|
|||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
)
|
)
|
||||||
val frames: SharedFlow<SstvFrame> = _frames
|
val frames: SharedFlow<SstvFrame> = _frames
|
||||||
val supportedModes: List<String> = buildList {
|
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||||
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
|
// Normalize to a fixed RMS before processing so that both direct audio
|
||||||
|
// coupling and air-coupled microphone input decode with equal reliability.
|
||||||
|
normalise(samples)
|
||||||
val hasNewLines = decoder.process(samples, channelSelect)
|
val hasNewLines = decoder.process(samples, channelSelect)
|
||||||
if (hasNewLines) emitFrame()
|
if (hasNewLines) emitFrame()
|
||||||
}
|
}
|
||||||
@@ -64,12 +65,31 @@ class SstvDecoder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun emitFrame() {
|
private fun emitFrame() {
|
||||||
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
|
|
||||||
val imageWidth = imageBuffer.width
|
val imageWidth = imageBuffer.width
|
||||||
val imageHeight = imageBuffer.height
|
val imageHeight = imageBuffer.height
|
||||||
|
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
||||||
|
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
||||||
|
// the entire array and send padding pixels to the observer.
|
||||||
|
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
||||||
val modeName = decoder.currentMode.name
|
val modeName = decoder.currentMode.name
|
||||||
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
|
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||||
|
// FM demodulator well above its noise floor regardless of input gain.
|
||||||
|
private val targetRms = 0.25f
|
||||||
|
|
||||||
|
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
||||||
|
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
||||||
|
private fun normalise(buffer: FloatArray) {
|
||||||
|
var sumSq = 0f
|
||||||
|
for (s in buffer) sumSq += s * s
|
||||||
|
val rms = sqrt(sumSq / buffer.size)
|
||||||
|
if (rms > 1e-6f) {
|
||||||
|
val gain = targetRms / rms
|
||||||
|
for (i in buffer.indices) buffer[i] *= gain
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
||||||
@@ -136,6 +156,10 @@ internal class SyncPulseDetector(sampleRate: Int) {
|
|||||||
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
|
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
var detected = false
|
var detected = false
|
||||||
val channels = if (channelSelect > 0) 2 else 1
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
|
// NOTE: buffer[i] is overwritten in-place with the FM-demodulated frequency
|
||||||
|
// value for every mono sample (channelSelect == 0). DecoderEngine.process()
|
||||||
|
// reads back these values to populate scanLineBuffer. Callers must not reuse
|
||||||
|
// the buffer after this call.
|
||||||
for (i in 0 until buffer.size / channels) {
|
for (i in 0 until buffer.size / channels) {
|
||||||
when (channelSelect) {
|
when (channelSelect) {
|
||||||
1 -> baseBand.set(buffer[2 * i])
|
1 -> baseBand.set(buffer[2 * i])
|
||||||
@@ -200,7 +224,6 @@ internal class DecoderEngine(
|
|||||||
private val visBitLen: Int
|
private val visBitLen: Int
|
||||||
private val visLen: Int
|
private val visLen: Int
|
||||||
private val rawMode: SstvMode
|
private val rawMode: SstvMode
|
||||||
private val hfFaxMode: SstvMode
|
|
||||||
private val modes5ms: ArrayList<SstvMode>
|
private val modes5ms: ArrayList<SstvMode>
|
||||||
private val modes9ms: ArrayList<SstvMode>
|
private val modes9ms: ArrayList<SstvMode>
|
||||||
private val modes20ms: ArrayList<SstvMode>
|
private val modes20ms: ArrayList<SstvMode>
|
||||||
@@ -217,6 +240,10 @@ internal class DecoderEngine(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
|
// Pre-allocate for the largest possible mode (PD-290: 800×616 = 492 800 ints)
|
||||||
|
// so that handleHeader/processPulse can reuse the array with a fill(0) instead
|
||||||
|
// of allocating a fresh IntArray on every new image, reducing GC pressure.
|
||||||
|
imageBuffer.pixels = IntArray(800 * 616)
|
||||||
val pfLen = round(0.0025 * sampleRate).toInt() or 1
|
val pfLen = round(0.0025 * sampleRate).toInt() or 1
|
||||||
pulseFilterDelay = (pfLen - 1) / 2
|
pulseFilterDelay = (pfLen - 1) / 2
|
||||||
pulseFilter = MovingAverage(pfLen)
|
pulseFilter = MovingAverage(pfLen)
|
||||||
@@ -231,7 +258,6 @@ internal class DecoderEngine(
|
|||||||
syncTolerance = round(0.03 * sampleRate).toInt()
|
syncTolerance = round(0.03 * sampleRate).toInt()
|
||||||
lineTolerance = round(0.001 * sampleRate).toInt()
|
lineTolerance = round(0.001 * sampleRate).toInt()
|
||||||
rawMode = RawMode(rawName, sampleRate)
|
rawMode = RawMode(rawName, sampleRate)
|
||||||
hfFaxMode = HfFaxMode(sampleRate)
|
|
||||||
val robot36 = Robot36Mode(sampleRate)
|
val robot36 = Robot36Mode(sampleRate)
|
||||||
currentMode = robot36
|
currentMode = robot36
|
||||||
curLineSamples = robot36.scanLineSamples
|
curLineSamples = robot36.scanLineSamples
|
||||||
@@ -301,11 +327,7 @@ internal class DecoderEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setMode(name: String) {
|
fun setMode(name: String) {
|
||||||
if (rawMode.name == name) {
|
val mode = allModes.firstOrNull { it.name == name }
|
||||||
lockMode = true; imageBuffer.line = -1; currentMode = rawMode; return
|
|
||||||
}
|
|
||||||
var mode = allModes.firstOrNull { it.name == name }
|
|
||||||
if (mode == null && hfFaxMode.name == name) mode = hfFaxMode
|
|
||||||
if (mode == currentMode) {
|
if (mode == currentMode) {
|
||||||
lockMode = true; return
|
lockMode = true; return
|
||||||
}
|
}
|
||||||
@@ -335,6 +357,10 @@ internal class DecoderEngine(
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scopeBuffer is twice the display height. Each decoded scan line is written
|
||||||
|
// to both the current rolling position (top half, wraps at height/2) and the
|
||||||
|
// same row offset in the bottom half. The UI displays a window that always
|
||||||
|
// spans the half-height boundary, giving a seamless non-wrapping scroll effect.
|
||||||
private fun copyUnscaled() {
|
private fun copyUnscaled() {
|
||||||
val w = minOf(scopeBuffer.width, pixelBuffer.width)
|
val w = minOf(scopeBuffer.width, pixelBuffer.width)
|
||||||
for (row in 0 until pixelBuffer.height) {
|
for (row in 0 until pixelBuffer.height) {
|
||||||
@@ -424,6 +450,9 @@ internal class DecoderEngine(
|
|||||||
if ((amount <= 0) || (amount > sample)) return
|
if ((amount <= 0) || (amount > sample)) return
|
||||||
sample -= amount; leaderBreak -= amount; lastSync -= amount
|
sample -= amount; leaderBreak -= amount; lastSync -= amount
|
||||||
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
|
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
|
||||||
|
// Discard already-decoded samples by sliding the live region back to index 0.
|
||||||
|
// System.arraycopy handles the overlapping regions correctly and is a native
|
||||||
|
// memcpy on JVM, so this is fast despite moving the full remaining window.
|
||||||
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
|
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +523,7 @@ internal class DecoderEngine(
|
|||||||
if (lockMode && mode != currentMode) return false
|
if (lockMode && mode != currentMode) return false
|
||||||
mode.resetState()
|
mode.resetState()
|
||||||
imageBuffer.width = mode.width; imageBuffer.height = mode.height
|
imageBuffer.width = mode.width; imageBuffer.height = mode.height
|
||||||
imageBuffer.pixels = IntArray(mode.width * mode.height); imageBuffer.line = 0
|
imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0
|
||||||
currentMode = mode
|
currentMode = mode
|
||||||
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
|
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
|
||||||
var oldest = lastSync - (pulses.size - 1) * curLineSamples
|
var oldest = lastSync - (pulses.size - 1) * curLineSamples
|
||||||
@@ -527,6 +556,15 @@ internal class DecoderEngine(
|
|||||||
var changed = false
|
var changed = false
|
||||||
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
|
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
|
||||||
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
|
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
|
||||||
|
// Try continuous decoding
|
||||||
|
if (lockMode && imageBuffer.line == -1 && currentMode != rawMode) {
|
||||||
|
currentMode.resetState()
|
||||||
|
imageBuffer.width = currentMode.width
|
||||||
|
imageBuffer.height = currentMode.height
|
||||||
|
imageBuffer.pixels.fill(0, 0, currentMode.width * currentMode.height)
|
||||||
|
imageBuffer.line = 0
|
||||||
|
drawLines(0xff000000.toInt(), 10); drawLines(0xffffff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
|
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
|
||||||
changed =
|
changed =
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.sstv
|
package com.rtbishop.look4sat.core.domain.sstv
|
||||||
|
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.atan2
|
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
@@ -34,7 +33,6 @@ internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
|
|||||||
fun set(real: Float): Complex = set(real, 0f)
|
fun set(real: Float): Complex = set(real, 0f)
|
||||||
|
|
||||||
fun abs(): Float = sqrt(real * real + imag * imag)
|
fun abs(): Float = sqrt(real * real + imag * imag)
|
||||||
fun arg(): Float = atan2(imag, real)
|
|
||||||
|
|
||||||
fun mul(other: Complex): Complex {
|
fun mul(other: Complex): Complex {
|
||||||
val tmp = real * other.real - imag * other.imag
|
val tmp = real * other.real - imag * other.imag
|
||||||
@@ -74,21 +72,21 @@ internal object WindowFunctions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// O(1) ring buffer — simpler and faster than a segment tree for the short window
|
||||||
|
// lengths used here (≤512 samples). Float32 accumulated drift over such windows
|
||||||
|
// is ~6e-5, negligible for audio-frequency processing.
|
||||||
internal open class MovingSum(val length: Int) {
|
internal open class MovingSum(val length: Int) {
|
||||||
private val tree = FloatArray(2 * length)
|
private val buf = FloatArray(length)
|
||||||
private var leaf = length
|
private var pos = 0
|
||||||
|
private var runningSum = 0f
|
||||||
|
|
||||||
fun add(input: Float) {
|
fun add(input: Float) {
|
||||||
tree[leaf] = input
|
runningSum += input - buf[pos]
|
||||||
var child = leaf
|
buf[pos] = input
|
||||||
var parent = leaf / 2
|
if (++pos >= length) pos = 0
|
||||||
while (parent > 0) {
|
|
||||||
tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
|
|
||||||
}
|
|
||||||
if (++leaf >= tree.size) leaf = length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sum(): Float = tree[1]
|
fun sum(): Float = runningSum
|
||||||
fun sum(input: Float): Float {
|
fun sum(input: Float): Float {
|
||||||
add(input); return sum()
|
add(input); return sum()
|
||||||
}
|
}
|
||||||
@@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) {
|
|||||||
val omega = 2 * PI * freq / rate
|
val omega = 2 * PI * freq / rate
|
||||||
Complex(cos(omega).toFloat(), sin(omega).toFloat())
|
Complex(cos(omega).toFloat(), sin(omega).toFloat())
|
||||||
}
|
}
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
fun rotate(): Complex = value.div(value.mul(delta).abs())
|
// Renormalize every 512 rotations to prevent magnitude drift accumulation,
|
||||||
|
// eliminating the per-sample sqrt without sacrificing demodulation accuracy.
|
||||||
|
fun rotate(): Complex {
|
||||||
|
value.mul(delta)
|
||||||
|
if (++count == 512) { value.div(value.abs()); count = 0 }
|
||||||
|
return value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
||||||
@@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
|||||||
private var prev = 0f
|
private var prev = 0f
|
||||||
|
|
||||||
fun demodulate(input: Complex): Float {
|
fun demodulate(input: Complex): Float {
|
||||||
val phase = input.arg()
|
// Use fast polynomial atan2 instead of the exact trigonometric call.
|
||||||
|
// Max error ~0.005 rad translates to <1 Hz frequency error at 44100 Hz,
|
||||||
|
// well within the 50 Hz sync tolerance.
|
||||||
|
val phase = fastAtan2(input.imag, input.real)
|
||||||
var delta = phase - prev; prev = phase
|
var delta = phase - prev; prev = phase
|
||||||
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
|
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
|
||||||
return scale * delta
|
return scale * delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rajan's polynomial approximation of atan2 — avoids a transcendental call
|
||||||
|
// in the per-sample hot path (~44 k calls/s at 44100 Hz sample rate).
|
||||||
|
private fun fastAtan2(y: Float, x: Float): Float {
|
||||||
|
val absY = kotlin.math.abs(y) + 1e-10f
|
||||||
|
val r: Float
|
||||||
|
val angle: Float
|
||||||
|
if (x >= 0f) {
|
||||||
|
r = (x - absY) / (x + absY)
|
||||||
|
angle = 0.1963f * r * r * r - 0.9817f * r + pi / 4f
|
||||||
|
} else {
|
||||||
|
r = (x + absY) / (absY - x)
|
||||||
|
angle = 0.1963f * r * r * r - 0.9817f * r + 3f * pi / 4f
|
||||||
|
}
|
||||||
|
return if (y < 0f) -angle else angle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class ComplexFirFilter(val length: Int) {
|
internal class ComplexFirFilter(val length: Int) {
|
||||||
|
|||||||
@@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode {
|
|||||||
val lPos = lumBegin + (i * lumSamples) / width
|
val lPos = lumBegin + (i * lumSamples) / width
|
||||||
val cPos = chromBegin + (i * chromSamples) / width
|
val cPos = chromBegin + (i * chromSamples) / width
|
||||||
if (even) {
|
if (even) {
|
||||||
|
// Even line: store Y in the red channel slot and Cr in the blue slot,
|
||||||
|
// using ColorConverter.rgb() as a convenient 3×byte packer (not RGB).
|
||||||
|
// The odd line will read these back and combine with its own Cb to
|
||||||
|
// produce the final YUV→RGB conversion for both rows.
|
||||||
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
|
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
|
||||||
} else {
|
} else {
|
||||||
val evenYuv = pixelBuffer.pixels[i]
|
val evenYuv = pixelBuffer.pixels[i]
|
||||||
|
// Even pixel packing: 0xAARRGGBB → Y=RR, Cb=GG(unused), Cr=BB
|
||||||
|
// Odd pixel: Y=lPos, Cb=cPos, Cr=(borrowed from even's BB slot)
|
||||||
|
// Merge: take Y+Cr from even row (bits 0x00ff00ff) and Cb from odd (0x0000ff00).
|
||||||
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
|
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
|
||||||
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
|
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
|
||||||
pixelBuffer.pixels[i + width] =
|
pixelBuffer.pixels[i + width] =
|
||||||
@@ -389,53 +396,6 @@ internal class PdMode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class HfFaxMode(private val sampleRate: Int) : SstvMode {
|
|
||||||
override val name = "HF Fax"
|
|
||||||
override val visCode = -1
|
|
||||||
override val width = 640
|
|
||||||
override val height = 1200
|
|
||||||
override val firstPixelSampleIndex = 0
|
|
||||||
override val firstSyncPulseIndex = -1
|
|
||||||
override val scanLineSamples get() = sampleRate / 2
|
|
||||||
|
|
||||||
private val ema = Ema()
|
|
||||||
private val cumulated = FloatArray(width)
|
|
||||||
|
|
||||||
var horizontalShift = 0; private set
|
|
||||||
|
|
||||||
override fun decodeScanLine(
|
|
||||||
pixelBuffer: PixelBuffer,
|
|
||||||
scratch: FloatArray,
|
|
||||||
scanLine: FloatArray,
|
|
||||||
scopeWidth: Int,
|
|
||||||
syncPulseIndex: Int,
|
|
||||||
lineSamples: Int,
|
|
||||||
freqOffset: Float
|
|
||||||
): Boolean {
|
|
||||||
if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
|
|
||||||
ema.setCutoff(width.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
|
|
||||||
for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
|
||||||
ema.reset()
|
|
||||||
for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
|
||||||
var bestIdx = 0
|
|
||||||
var bestVal = 0f
|
|
||||||
for (i in 0 until width) {
|
|
||||||
val pos = (i * lineSamples) / width
|
|
||||||
val color = ColorConverter.gray(scratch[pos])
|
|
||||||
pixelBuffer.pixels[i] = color
|
|
||||||
// Grayscale: R==G==B, so luminance is simply the channel value normalized
|
|
||||||
val gray = (color and 0xFF) / 255f
|
|
||||||
cumulated[i] = cumulated[i] * 0.99f + gray * 0.01f
|
|
||||||
if (cumulated[i] > bestVal) {
|
|
||||||
bestIdx = i; bestVal = cumulated[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
horizontalShift = bestIdx
|
|
||||||
pixelBuffer.width = width; pixelBuffer.height = 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
|
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
|
||||||
override val visCode = -1
|
override val visCode = -1
|
||||||
override val width = -1
|
override val width = -1
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -49,12 +50,21 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
@@ -387,3 +397,76 @@ fun elevationColor(elevation: Double): Color {
|
|||||||
else -> Color(0xFF66BB6A) // soft green for high elevation
|
else -> Color(0xFF66BB6A) // soft green for high elevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OutlinedText(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fillColor: Color = Color.Unspecified,
|
||||||
|
outlineColor: Color,
|
||||||
|
fontSize: TextUnit = TextUnit.Unspecified,
|
||||||
|
fontStyle: FontStyle? = null,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
fontFamily: FontFamily? = null,
|
||||||
|
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
textAlign: TextAlign? = null,
|
||||||
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
|
softWrap: Boolean = true,
|
||||||
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
|
minLines: Int = 1,
|
||||||
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
|
style: TextStyle = LocalTextStyle.current,
|
||||||
|
outlineDrawStyle: Stroke = Stroke(width = 8f),
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||||
|
color = outlineColor,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = null,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
|
maxLines = maxLines,
|
||||||
|
minLines = minLines,
|
||||||
|
onTextLayout = onTextLayout,
|
||||||
|
style = style.copy(shadow = null, drawStyle = outlineDrawStyle),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = fillColor,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
|
maxLines = maxLines,
|
||||||
|
minLines = minLines,
|
||||||
|
onTextLayout = onTextLayout,
|
||||||
|
style = style,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats a frequency in Hz as "MMM.KKK.HHH" (e.g. 145.825.000) or "---"
|
||||||
|
fun formatFrequency(frequencyHz: Long): String {
|
||||||
|
if (frequencyHz <= 0) return "---"
|
||||||
|
val mhz = frequencyHz / 1_000_000
|
||||||
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
|
val hz = frequencyHz % 1_000
|
||||||
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
|
}
|
||||||
@@ -38,3 +38,33 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object RadarDestination : NavKey
|
||||||
|
|
||||||
|
interface IDeeplinkMatcher {
|
||||||
|
fun match(deeplink: String): NavKey?
|
||||||
|
}
|
||||||
|
|
||||||
|
object PassDetailsMatcher : IDeeplinkMatcher {
|
||||||
|
val passDetailsRegex = """https://github.com/rt-bishop/Look4Sat/passes/(.*)""".toRegex()
|
||||||
|
|
||||||
|
override fun match(deeplink: String): NavKey? {
|
||||||
|
val passMatch = passDetailsRegex.find(deeplink)
|
||||||
|
passMatch?.let { match ->
|
||||||
|
val passId = match.groupValues[1]
|
||||||
|
if (passId.isNotEmpty()) return RadarDestination
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes) {
|
||||||
|
|
||||||
|
private val matchers: List<IDeeplinkMatcher> = listOf(PassDetailsMatcher)
|
||||||
|
|
||||||
|
fun resolve(deeplink: String): NavKey {
|
||||||
|
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
||||||
|
return fallbackDestination
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,9 +49,9 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Merged RadarScreen and RadioControlScreen functionality
|
* Fixed transceivers retention on a failed fetch from server
|
||||||
\n\n* Added SSTV image decoding functionality to RadarScreen
|
\n\n* Fixed SSTV frequency display, no transceiver selected hint
|
||||||
\n\n* Added colored elevation and decay check to satellite passes
|
\n\n* Fixed All satellite category retention on Satellites screen
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DeeplinkResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsDefaultDestination() {
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
val result = deeplinkResolver.resolve("/deeplink")
|
||||||
|
assert(result == Screen.Passes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsRadarDestination() {
|
||||||
|
val passId = "some-pass-id"
|
||||||
|
val passDeeplink = "https://github.com/rt-bishop/Look4Sat/passes/$passId"
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
val result = deeplinkResolver.resolve(passDeeplink)
|
||||||
|
assert(result == RadarDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
* Merged RadarScreen and RadioControlScreen functionality
|
* Fixed transceivers retention on a failed fetch from server
|
||||||
* Added SSTV image decoding functionality to RadarScreen
|
* Fixed SSTV frequency display, no transceiver selected hint
|
||||||
* Added colored elevation and decay check to satellite passes
|
* Fixed All satellite category retention on Satellites screen
|
||||||
@@ -66,6 +66,7 @@ import com.rtbishop.look4sat.core.presentation.NextPassRow
|
|||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
@@ -170,13 +171,14 @@ private fun PagerCard(
|
|||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
when (pages[pageIndex]) {
|
when (pages[pageIndex]) {
|
||||||
RadarPage.Transceivers -> TransceiversPage(
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
transceivers = uiState.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
selectedUuid = uiState.selectedTransmitterUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
radioControl = uiState.radioControl,
|
radioControl = uiState.radioControl,
|
||||||
onAction = onAction
|
onAction = onAction
|
||||||
)
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
@@ -189,7 +191,9 @@ private fun PagerCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||||
val satellitePos = uiState.orbitalPos
|
val satellitePos = uiState.orbitalPos
|
||||||
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
|
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||||
|
// Always call these composables unconditionally — conditional composable calls violate
|
||||||
|
// Compose's slot-table stability rules and can crash or produce incorrect state
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
||||||
val borderAlpha by infiniteTransition.animateFloat(
|
val borderAlpha by infiniteTransition.animateFloat(
|
||||||
initialValue = 1.0f,
|
initialValue = 1.0f,
|
||||||
@@ -200,14 +204,13 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
|||||||
),
|
),
|
||||||
label = "eclipsedBorderAlpha"
|
label = "eclipsedBorderAlpha"
|
||||||
)
|
)
|
||||||
|
val borderModifier = if (shouldAnimateBorder) {
|
||||||
Modifier.border(
|
Modifier.border(
|
||||||
width = 0.5.dp,
|
width = 0.5.dp,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
||||||
shape = MaterialTheme.shapes.medium
|
shape = MaterialTheme.shapes.medium
|
||||||
)
|
)
|
||||||
} else {
|
} else Modifier
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
val position = uiState.orbitalPos
|
val position = uiState.orbitalPos
|
||||||
@@ -294,4 +297,3 @@ private fun RadarLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ data class RadioPanelState(
|
|||||||
data class RadioControlSubState(
|
data class RadioControlSubState(
|
||||||
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
||||||
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
||||||
val transponders: List<SatRadio> = emptyList(),
|
|
||||||
val selectedTransponderUuid: String? = null,
|
val selectedTransponderUuid: String? = null,
|
||||||
val txBaseFrequencyHz: Long? = null,
|
val txBaseFrequencyHz: Long? = null,
|
||||||
val ctcssTone: Double? = null,
|
val ctcssTone: Double? = null,
|
||||||
@@ -42,11 +41,16 @@ data class RadioControlSubState(
|
|||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class TransceiverSubState(
|
||||||
|
val transmitters: List<SatRadio> = emptyList(),
|
||||||
|
val selectedUuid: String? = null,
|
||||||
|
val selectedFrequency: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class RadarState(
|
data class RadarState(
|
||||||
val currentPass: OrbitalPass? = null,
|
val currentPass: OrbitalPass? = null,
|
||||||
val currentTime: String = "00:00:00",
|
val currentTime: String = "00:00:00",
|
||||||
val isTimeAos: Boolean = true,
|
val isTimeAos: Boolean = true,
|
||||||
val isLos: Boolean = false,
|
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
||||||
val orbitalPos: OrbitalPos? = null,
|
val orbitalPos: OrbitalPos? = null,
|
||||||
@@ -55,17 +59,16 @@ data class RadarState(
|
|||||||
val shouldUseCompass: Boolean = false,
|
val shouldUseCompass: Boolean = false,
|
||||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
val transmitters: List<SatRadio> = emptyList(),
|
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||||
val selectedTransmitterUuid: String? = null,
|
|
||||||
val selectedFrequency: Long? = null,
|
|
||||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||||
val sstv: SstvSubState = SstvSubState()
|
val sstv: SstvSubState = SstvSubState()
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class SstvStatus { Idle, Recording, Saving }
|
enum class SstvStatus { Idle, Recording }
|
||||||
|
|
||||||
data class SstvSubState(
|
data class SstvSubState(
|
||||||
val status: SstvStatus = SstvStatus.Idle,
|
val status: SstvStatus = SstvStatus.Idle,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
val hasPermission: Boolean = false,
|
val hasPermission: Boolean = false,
|
||||||
val selectedMode: String = "Auto",
|
val selectedMode: String = "Auto",
|
||||||
val supportedModes: List<String> = emptyList(),
|
val supportedModes: List<String> = emptyList(),
|
||||||
@@ -77,7 +80,6 @@ sealed interface RadarAction {
|
|||||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||||
|
|
||||||
// Radio control actions
|
// Radio control actions
|
||||||
data class SelectTransponder(val uuid: String) : RadarAction
|
|
||||||
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
||||||
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
||||||
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
@@ -64,7 +65,7 @@ import kotlin.math.sin
|
|||||||
|
|
||||||
private const val CIRCLES = 3
|
private const val CIRCLES = 3
|
||||||
private const val STROKE_WIDTH = 6f
|
private const val STROKE_WIDTH = 6f
|
||||||
private const val SWEEP_INCREMENT = 360f / 12f / 60f
|
private const val SWEEP_DURATION_MS = 8_000
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarViewCompose(
|
fun RadarViewCompose(
|
||||||
@@ -88,23 +89,44 @@ fun RadarViewCompose(
|
|||||||
animationSpec = infiniteRepeatable(tween(1000)),
|
animationSpec = infiniteRepeatable(tween(1000)),
|
||||||
label = "animScale"
|
label = "animScale"
|
||||||
)
|
)
|
||||||
|
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
|
||||||
|
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||||
|
val sweepDegrees by sweepTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||||
|
label = "sweepDegrees"
|
||||||
|
)
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
val sunPainter = painterResource(R.drawable.ic_sun)
|
val sunPainter = painterResource(R.drawable.ic_sun)
|
||||||
val moonPainter = painterResource(R.drawable.ic_moon)
|
val moonPainter = painterResource(R.drawable.ic_moon)
|
||||||
var sweepDegrees by remember { mutableFloatStateOf(0f) }
|
// Track path cache — keyed by both canvas size and items reference so it rebuilds
|
||||||
|
// when the satellite track data arrives asynchronously after the first composition
|
||||||
var cachedRadius by remember { mutableFloatStateOf(0f) }
|
var cachedRadius by remember { mutableFloatStateOf(0f) }
|
||||||
|
var cachedItemsRef by remember { mutableStateOf<List<OrbitalPos>>(emptyList()) }
|
||||||
|
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
||||||
var trackPath by remember { mutableStateOf(Path()) }
|
var trackPath by remember { mutableStateOf(Path()) }
|
||||||
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
||||||
|
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
||||||
|
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
||||||
|
|
||||||
Canvas(modifier = modifier.aspectRatio(1f)) {
|
Canvas(modifier = modifier.aspectRatio(1f)) {
|
||||||
val radius = size.minDimension / 2f * 0.95f
|
val radius = size.minDimension / 2f * 0.95f
|
||||||
if (radius != cachedRadius) {
|
// Rebuild track path and sweep brush when canvas size or track data changes
|
||||||
|
if (radius != cachedRadius || items !== cachedItemsRef) {
|
||||||
trackPath = createTrackPath(items, radius)
|
trackPath = createTrackPath(items, radius)
|
||||||
trackEffect = createTrackEffect(trackPath)
|
trackEffect = createTrackEffect(trackPath)
|
||||||
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedRadius = radius
|
cachedRadius = radius
|
||||||
|
cachedItemsRef = items
|
||||||
|
cachedSweepColor = primaryColor
|
||||||
|
} else if (primaryColor != cachedSweepColor) {
|
||||||
|
// Rebuild brush on theme change without waiting for a size change
|
||||||
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
|
cachedSweepColor = primaryColor
|
||||||
}
|
}
|
||||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
||||||
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor)
|
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||||
drawRadar(radius, radarColor)
|
drawRadar(radius, radarColor)
|
||||||
drawElevationLabels(radius, primaryColor, measurer)
|
drawElevationLabels(radius, primaryColor, measurer)
|
||||||
translate(center.x, center.y) {
|
translate(center.x, center.y) {
|
||||||
@@ -127,7 +149,6 @@ fun RadarViewCompose(
|
|||||||
}
|
}
|
||||||
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
|
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
|
||||||
}
|
}
|
||||||
sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,10 +193,13 @@ private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Co
|
|||||||
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
|
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
|
private fun makeSweepBrush(center: Offset, color: Color): ShaderBrush {
|
||||||
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
||||||
val colorStops = listOf(0.64f, 0.995f, 1f)
|
val colorStops = listOf(0.64f, 0.995f, 1f)
|
||||||
val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
return ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, brush: ShaderBrush) {
|
||||||
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -48,7 +49,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class RadarViewModel(
|
class RadarViewModel(
|
||||||
private val bluetoothReporter: IReporter,
|
private val bluetoothReporter: IReporter,
|
||||||
@@ -69,10 +70,15 @@ class RadarViewModel(
|
|||||||
private var sstvDecoder: SstvDecoder? = null
|
private var sstvDecoder: SstvDecoder? = null
|
||||||
private var sstvRecordingJob: Job? = null
|
private var sstvRecordingJob: Job? = null
|
||||||
|
|
||||||
|
// Celestial positions change slowly, recompute at most once per minute
|
||||||
|
private var lastCelestialUpdateMs = 0L
|
||||||
|
private var cachedSunPos: CelestialComputer.SunPosition? = null
|
||||||
|
private var cachedMoonPos: CelestialComputer.MoonPosition? = null
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
RadarState(
|
RadarState(
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
orientationValues = sensorsRepo.orientation.value,
|
orientationValues = sensorsRepo.sensorData.value,
|
||||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||||
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||||
@@ -91,7 +97,7 @@ class RadarViewModel(
|
|||||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sensorsRepo.enableSensor()
|
sensorsRepo.enableSensor()
|
||||||
sensorsRepo.orientation.collect { data ->
|
sensorsRepo.sensorData.collect { data ->
|
||||||
val orientationValues = (data.first + magDeclination) to data.second
|
val orientationValues = (data.first + magDeclination) to data.second
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||||
}
|
}
|
||||||
@@ -101,52 +107,75 @@ class RadarViewModel(
|
|||||||
private fun collectSettingsChanges() {
|
private fun collectSettingsChanges() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUtc = settings.stateOfUtc,
|
||||||
|
shouldShowSweep = settings.stateOfSweep,
|
||||||
|
shouldUseCompass = settings.stateOfSensors
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pass loading split into focused functions ---
|
||||||
|
|
||||||
private fun collectPassAndStartTickLoop() {
|
private fun collectPassAndStartTickLoop() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val pass = findCurrentPass() ?: return@launch
|
||||||
|
val allRadios = loadPassData(pass)
|
||||||
|
while (isActive) {
|
||||||
|
tickPass(pass, allRadios)
|
||||||
|
delay(1000.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findCurrentPass(): OrbitalPass? {
|
||||||
val passes = satelliteRepo.passes.value
|
val passes = satelliteRepo.passes.value
|
||||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
|
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||||
_uiState.update { it.copy(currentPass = pass) }
|
?: passes.firstOrNull()
|
||||||
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
|
|
||||||
transponders = transmittersList.filter { it.downlinkLow != null }
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
|
|
||||||
}
|
}
|
||||||
if (transmittersList.isNotEmpty()) {
|
|
||||||
val firstUuid = transmittersList.first().uuid
|
// Loads transmitters and satellite track for pass, sets initial state, returns full radio list
|
||||||
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
|
private suspend fun loadPassData(pass: OrbitalPass): List<SatRadio> {
|
||||||
|
_uiState.update { it.copy(currentPass = pass) }
|
||||||
|
val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||||
|
transponders = allRadios.filter { it.downlinkLow != null }
|
||||||
|
if (allRadios.isNotEmpty()) {
|
||||||
|
val firstUuid = allRadios.first().uuid
|
||||||
|
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
|
||||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||||
}
|
}
|
||||||
if (!pass.isDeepSpace) {
|
if (!pass.isDeepSpace) {
|
||||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||||
_uiState.update { it.copy(satTrack = track) }
|
_uiState.update { it.copy(satTrack = track) }
|
||||||
}
|
}
|
||||||
while (isActive) {
|
return allRadios
|
||||||
tickPass(pass, transmittersList)
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) {
|
// --- Per-second tick ---
|
||||||
|
|
||||||
|
private suspend fun tickPass(pass: OrbitalPass, allRadios: List<SatRadio>) {
|
||||||
val timeNow = System.currentTimeMillis()
|
val timeNow = System.currentTimeMillis()
|
||||||
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||||
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
|
||||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
// Recompute celestial positions at most once per minute (they move very slowly)
|
||||||
|
if (timeNow - lastCelestialUpdateMs >= 60_000L) {
|
||||||
|
cachedSunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||||
|
cachedMoonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||||
|
lastCelestialUpdateMs = timeNow
|
||||||
|
}
|
||||||
|
|
||||||
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||||
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
currentTime = time, isTimeAos = isAos, isLos = isLos,
|
currentTime = time, isTimeAos = isAos,
|
||||||
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos
|
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
processRadios(transmittersList, pass.orbitalObject, timeNow)
|
processRadios(allRadios, pass.orbitalObject, timeNow)
|
||||||
sendPassData(pos)
|
sendPassData(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,24 +220,15 @@ class RadarViewModel(
|
|||||||
when (action) {
|
when (action) {
|
||||||
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
||||||
is RadarAction.SelectTransmitter -> {
|
is RadarAction.SelectTransmitter -> {
|
||||||
val previousUuid = _uiState.value.selectedTransmitterUuid
|
// Compute toggle state before the update so we don't read post-update value
|
||||||
_uiState.update {
|
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||||
val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid
|
val newUuid = if (isTogglingOff) null else action.uuid
|
||||||
it.copy(selectedTransmitterUuid = newUuid)
|
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||||
|
// Only update the tracking service when selecting a different transponder to
|
||||||
|
// avoid resetting a user-adjusted TX base on re-expand
|
||||||
|
if (!isTogglingOff) {
|
||||||
|
transponders.find { it.uuid == action.uuid }?.let { trackingService.setTransponder(it) }
|
||||||
}
|
}
|
||||||
// Also set this as the active transponder for radio tracking.
|
|
||||||
// Always use the original (un-Dopplered) transponders list so that the
|
|
||||||
// TX base frequency is computed from the clean nominal values.
|
|
||||||
// Only update the service when selecting a *different* transponder to
|
|
||||||
// avoid resetting a user-adjusted TX base on re-expand.
|
|
||||||
val selected = transponders.find { it.uuid == action.uuid }
|
|
||||||
if (selected != null && _uiState.value.selectedTransmitterUuid != null && previousUuid != action.uuid) {
|
|
||||||
trackingService.setTransponder(selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadarAction.SelectTransponder -> {
|
|
||||||
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
|
||||||
trackingService.setTransponder(transponder)
|
|
||||||
}
|
}
|
||||||
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||||
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||||
@@ -236,10 +256,10 @@ class RadarViewModel(
|
|||||||
RadarAction.SstvSaveImage -> {
|
RadarAction.SstvSaveImage -> {
|
||||||
val frame = _uiState.value.sstv.currentFrame ?: return
|
val frame = _uiState.value.sstv.currentFrame ?: return
|
||||||
val pixels = frame.imagePixels ?: return
|
val pixels = frame.imagePixels ?: return
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Saving)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = true)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = false)) }
|
||||||
showToast("Image saved")
|
showToast("Image saved")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +269,7 @@ class RadarViewModel(
|
|||||||
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
||||||
}
|
}
|
||||||
RadarAction.SstvReset -> {
|
RadarAction.SstvReset -> {
|
||||||
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
|
sstvDecoder?.clearPixels()
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,13 +305,14 @@ class RadarViewModel(
|
|||||||
frequencyEnabled: Boolean,
|
frequencyEnabled: Boolean,
|
||||||
frequencyFormat: String
|
frequencyFormat: String
|
||||||
) {
|
) {
|
||||||
if (rotatorEnabled) {
|
// Only send rotator commands when the satellite is above the horizon
|
||||||
|
if (rotatorEnabled && orbitalPos.aboveHorizon) {
|
||||||
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
||||||
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
||||||
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
||||||
}
|
}
|
||||||
if (frequencyEnabled) {
|
if (frequencyEnabled) {
|
||||||
_uiState.value.selectedFrequency?.let { freq ->
|
_uiState.value.transceivers.selectedFrequency?.let { freq ->
|
||||||
reporter.reportFrequency(frequencyFormat, freq)
|
reporter.reportFrequency(frequencyFormat, freq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,12 +320,9 @@ class RadarViewModel(
|
|||||||
|
|
||||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
||||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
||||||
val isFreqEnabled =
|
|
||||||
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
// Derive the frequency to report from the user's current selection (if any)
|
val freq = if (state.transceivers.selectedUuid != null) {
|
||||||
val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) {
|
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid }
|
|
||||||
selectedRadio?.let { radio ->
|
selectedRadio?.let { radio ->
|
||||||
val low = radio.downlinkLow
|
val low = radio.downlinkLow
|
||||||
val high = radio.downlinkHigh
|
val high = radio.downlinkHigh
|
||||||
@@ -316,10 +334,11 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
if (state.transmitters == transmitters && state.selectedFrequency == freq) {
|
val current = state.transceivers
|
||||||
|
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(transmitters = transmitters, selectedFrequency = freq)
|
state.copy(transceivers = current.copy(transmitters = transmitters, selectedFrequency = freq))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +346,7 @@ class RadarViewModel(
|
|||||||
if (sstvDecoder == null) {
|
if (sstvDecoder == null) {
|
||||||
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
|
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
|
||||||
sstvDecoder = decoder
|
sstvDecoder = decoder
|
||||||
|
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
decoder.frames.collect { frame ->
|
decoder.frames.collect { frame ->
|
||||||
@@ -362,13 +382,6 @@ class RadarViewModel(
|
|||||||
218.1, 225.7, 233.6, 241.8, 250.3
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
)
|
)
|
||||||
|
|
||||||
fun formatFrequency(frequencyHz: Long): String {
|
|
||||||
if (frequencyHz <= 0) return "---"
|
|
||||||
val mhz = frequencyHz / 1_000_000
|
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
|
||||||
val hz = frequencyHz % 1_000
|
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
|
|||||||
@@ -55,12 +55,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
|
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SstvPage(
|
internal fun SstvPage(
|
||||||
sstv: SstvSubState,
|
sstv: SstvSubState,
|
||||||
|
dopplerFrequency: String?,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -180,13 +182,26 @@ internal fun SstvPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Bottom control bar — dark, blends with the black canvas
|
// Bottom control bar — dark, blends with the black canvas
|
||||||
Row(
|
Column(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
// Doppler-corrected downlink frequency hint
|
||||||
|
OutlinedText(
|
||||||
|
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fillColor = MaterialTheme.colorScheme.primary,
|
||||||
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
// Mode button — opens dialog
|
// Mode button — opens dialog
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
@@ -215,14 +230,13 @@ internal fun SstvPage(
|
|||||||
IconCard(
|
IconCard(
|
||||||
action = { onAction(RadarAction.SstvSaveImage) },
|
action = { onAction(RadarAction.SstvSaveImage) },
|
||||||
resId = R.drawable.ic_save,
|
resId = R.drawable.ic_save,
|
||||||
enabled = sstv.currentFrame?.imagePixels != null
|
enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
|
||||||
)
|
)
|
||||||
// Record / Stop button
|
// Record / Stop button
|
||||||
val playAction = {
|
val playAction = {
|
||||||
when (sstv.status) {
|
when (sstv.status) {
|
||||||
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
||||||
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
||||||
SstvStatus.Saving -> {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val playColors = CardDefaults.elevatedCardColors(
|
val playColors = CardDefaults.elevatedCardColors(
|
||||||
@@ -238,3 +252,4 @@ internal fun SstvPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,10 @@ 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.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TransceiversPage(
|
fun TransceiversPage(
|
||||||
@@ -82,7 +84,7 @@ fun TransceiversPage(
|
|||||||
if (selectedUuid != null) {
|
if (selectedUuid != null) {
|
||||||
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
kotlinx.coroutines.delay(300)
|
kotlinx.coroutines.delay(300.milliseconds)
|
||||||
listState.animateScrollToItem(index)
|
listState.animateScrollToItem(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,7 +327,7 @@ private fun ExpandedRadioControl(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
@@ -335,7 +337,7 @@ private fun ExpandedRadioControl(
|
|||||||
val upHigh = radio.uplinkHigh
|
val upHigh = radio.uplinkHigh
|
||||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||||
Text(
|
Text(
|
||||||
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
|
text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "440"
|
appVersionCode = "442"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.0"
|
appVersionName = "4.4.2"
|
||||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
||||||
compileSdk = "36"
|
compileSdk = "36"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -18,18 +18,18 @@ androidx-core-ktx = "1.18.0"
|
|||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.05.01"
|
compose-bom = "2026.06.00"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.10.0"
|
compose-lifecycle = "2.10.0"
|
||||||
compose-navigation3 = "1.1.2"
|
compose-navigation3 = "1.1.3"
|
||||||
|
|
||||||
google-ksp = "2.3.7"
|
google-ksp = "2.3.8"
|
||||||
|
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.0"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
other-okhttp = "5.3.2"
|
other-okhttp = "5.4.0"
|
||||||
other-osmdroid = "6.1.20"
|
other-osmdroid = "6.1.20"
|
||||||
|
|
||||||
test-junit4 = "4.13.2"
|
test-junit4 = "4.13.2"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Mon May 25 11:20:48 BST 2026
|
#Sun Jun 21 14:57:04 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in new issue
Block a user