mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 19:17:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b17ea2d918 | ||
|
|
976fdfd949 | ||
|
|
d8db258bd8 | ||
|
|
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.
|
||||
@@ -16,13 +16,13 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
@@ -58,10 +58,11 @@ jobs:
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
ruby-version: '3.4'
|
||||
|
||||
- name: Deploy Bundle to Google Play
|
||||
run: |
|
||||
gem install multi_json
|
||||
gem install fastlane --no-document
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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.
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
- Default to code changes only. Provide explanations in chat only.
|
||||
- If documentation seems useful, ask first before creating files.
|
||||
- Do NOT create any `.md` documentation files unless explicitly requested.
|
||||
- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
|
||||
- Prefer minimal diffs focused on requested implementation.
|
||||
- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested.
|
||||
@@ -1,110 +1,5 @@
|
||||
# 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
|
||||
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.
|
||||
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||
@@ -33,3 +33,13 @@ It is now and always will be completely ad-free and open-source.
|
||||
* Showing the satellite positional data, footprint and ground track on the map
|
||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission
|
||||
@@ -18,8 +19,7 @@
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -29,6 +29,14 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<meta-data
|
||||
|
||||
@@ -25,6 +25,10 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -62,6 +69,8 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
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
|
||||
|
||||
@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 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 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 context = LocalContext.current
|
||||
@@ -139,6 +182,7 @@ fun MainScreen() {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
|
||||
@@ -8,5 +8,6 @@ plugins {
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete::class.java) {
|
||||
description = "Cleans the build directory"
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission
|
||||
|
||||
+7
-4
@@ -17,6 +17,9 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
object Ft817CatProtocol {
|
||||
|
||||
const val CMD_SET_FREQ: Byte = 0x01
|
||||
@@ -50,7 +53,7 @@ object Ft817CatProtocol {
|
||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||
val freq10Hz = frequencyHz / 10
|
||||
val bcd = ByteArray(4)
|
||||
val digits = String.format("%08d", freq10Hz)
|
||||
val digits = String.format(Locale.US, "%08d", freq10Hz)
|
||||
for (i in 0 until 4) {
|
||||
val high = digits[i * 2] - '0'
|
||||
val low = digits[i * 2 + 1] - '0'
|
||||
@@ -78,8 +81,8 @@ object Ft817CatProtocol {
|
||||
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||
*/
|
||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||
val tone01Hz = (toneHz * 10).toLong()
|
||||
val digits = String.format("%04d", tone01Hz)
|
||||
val tone01Hz = (toneHz * 10).roundToLong()
|
||||
val digits = String.format(Locale.US, "%04d", tone01Hz)
|
||||
val bcd = ByteArray(2)
|
||||
for (i in 0 until 2) {
|
||||
val high = digits[i * 2] - '0'
|
||||
@@ -95,7 +98,7 @@ object Ft817CatProtocol {
|
||||
}
|
||||
|
||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
|
||||
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||
}
|
||||
|
||||
|
||||
+67
-30
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class Ft817Controller(
|
||||
private val bluetoothManager: BluetoothManager,
|
||||
@@ -39,10 +40,12 @@ class Ft817Controller(
|
||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||
private val ioMutex = Mutex()
|
||||
private val commandDelayMs = 200L
|
||||
private val maxAckReadFailures = 3
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var outputStream: OutputStream? = null
|
||||
private var inputStream: InputStream? = null
|
||||
private var ackReadFailureCount = 0
|
||||
|
||||
override var isConnected: Boolean = false
|
||||
private set
|
||||
@@ -57,6 +60,7 @@ class Ft817Controller(
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
inputStream = btSocket.inputStream
|
||||
ackReadFailureCount = 0
|
||||
isConnected = true
|
||||
Log.i(tag, "Connected to $deviceAddress")
|
||||
true
|
||||
@@ -79,6 +83,7 @@ class Ft817Controller(
|
||||
inputStream = null
|
||||
outputStream = null
|
||||
socket = null
|
||||
ackReadFailureCount = 0
|
||||
isConnected = false
|
||||
Log.i(tag, "Disconnected from $deviceAddress")
|
||||
}
|
||||
@@ -112,8 +117,8 @@ class Ft817Controller(
|
||||
ioMutex.withLock {
|
||||
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||
if (!sent) return@withContext null
|
||||
delay(commandDelayMs)
|
||||
val response = readResponse(5) ?: return@withContext null
|
||||
delay(commandDelayMs.milliseconds)
|
||||
val response = readResponse() ?: return@withContext null
|
||||
Ft817CatProtocol.parseReadResponse(response)
|
||||
}
|
||||
}
|
||||
@@ -127,44 +132,76 @@ class Ft817Controller(
|
||||
}
|
||||
|
||||
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||
return try {
|
||||
outputStream?.write(bytes) ?: return false
|
||||
outputStream?.flush()
|
||||
delay(commandDelayMs)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Send error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
outputStream?.write(bytes) ?: return@withContext false
|
||||
outputStream?.flush()
|
||||
delay(commandDelayMs.milliseconds)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Send error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||
if (!sendCommand(bytes)) return false
|
||||
return try {
|
||||
val ack = inputStream?.read() ?: return false
|
||||
ack == 0x00
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "ACK read error: ${e.message}")
|
||||
true // command was sent, ACK read failed - continue anyway
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val ack = inputStream?.read() ?: run {
|
||||
ackReadFailureCount = 0
|
||||
isConnected = false
|
||||
return@withContext false
|
||||
}
|
||||
if (ack < 0) {
|
||||
ackReadFailureCount = 0
|
||||
Log.i(tag, "ACK stream closed by remote device")
|
||||
isConnected = false
|
||||
return@withContext false
|
||||
}
|
||||
ackReadFailureCount = 0
|
||||
ack == 0x00
|
||||
} catch (e: Exception) {
|
||||
ackReadFailureCount += 1
|
||||
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
|
||||
if (ackReadFailureCount >= maxAckReadFailures) {
|
||||
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
|
||||
isConnected = false
|
||||
false
|
||||
} else {
|
||||
true // Command was sent, treat transient ACK read failures as best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readResponse(length: Int): ByteArray? {
|
||||
return try {
|
||||
val buffer = ByteArray(length)
|
||||
var read = 0
|
||||
while (read < length) {
|
||||
val count = inputStream?.read(buffer, read, length - read) ?: return null
|
||||
if (count < 0) return null
|
||||
read += count
|
||||
private suspend fun readResponse(): ByteArray? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val responseSize = 5
|
||||
val buffer = ByteArray(responseSize)
|
||||
var read = 0
|
||||
while (read < responseSize) {
|
||||
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
|
||||
isConnected = false
|
||||
return@withContext null
|
||||
}
|
||||
if (count < 0) {
|
||||
Log.i(tag, "Response stream closed by remote device")
|
||||
isConnected = false
|
||||
return@withContext null
|
||||
}
|
||||
read += count
|
||||
}
|
||||
buffer
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Read error: ${e.message}")
|
||||
isConnected = false
|
||||
null
|
||||
}
|
||||
buffer
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Read error: ${e.message}")
|
||||
isConnected = false
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,9 @@ package com.rtbishop.look4sat.core.data.injection
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import android.view.WindowManager
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
@@ -120,9 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideSensorsRepo(): ISensorsRepo {
|
||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
return SensorsRepo(manager,sensor,window)
|
||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||
return SensorsRepo(manager, displayManager)
|
||||
}
|
||||
|
||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||
|
||||
@@ -22,8 +22,9 @@ import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.round
|
||||
|
||||
private const val SMOOTHING_FACTOR = 0.15f
|
||||
private const val SENSOR_RATE_US = 16_000
|
||||
|
||||
class SensorsRepo(
|
||||
private val sensorManager: SensorManager,
|
||||
private val sensor: Sensor?,
|
||||
private val windowManager: WindowManager
|
||||
) : SensorEventListener, ISensorsRepo {
|
||||
private val displayManager: DisplayManager?
|
||||
) : ISensorsRepo, SensorEventListener {
|
||||
|
||||
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 tempMatrix = FloatArray(9)
|
||||
private val orientationValues = FloatArray(3)
|
||||
@@ -45,11 +49,7 @@ class SensorsRepo(
|
||||
private var smoothPitch = 0f
|
||||
private var hasInitialReading = false
|
||||
|
||||
companion object {
|
||||
private const val SMOOTHING_FACTOR = 0.15f
|
||||
}
|
||||
|
||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||
|
||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||
return GeomagneticField(
|
||||
@@ -62,7 +62,7 @@ class SensorsRepo(
|
||||
|
||||
override fun enableSensor() {
|
||||
hasInitialReading = false
|
||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
||||
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||
}
|
||||
|
||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||
@@ -70,16 +70,11 @@ class SensorsRepo(
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||
|
||||
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 {
|
||||
return try {
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.rotation
|
||||
} catch (_: Exception) {
|
||||
Surface.ROTATION_0
|
||||
}
|
||||
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||
}
|
||||
|
||||
private fun remapForRotation(rotation: Int) {
|
||||
@@ -101,30 +96,23 @@ class SensorsRepo(
|
||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||
}
|
||||
|
||||
private fun updateOrientation(rotationVector: FloatArray) {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
||||
private fun handleSensorEvent(event: SensorEvent) {
|
||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||
remapForRotation(getDisplayRotation())
|
||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
||||
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
||||
|
||||
if (!hasInitialReading) {
|
||||
smoothAzimuth = magneticAzimuth
|
||||
smoothAzimuth = azimuth
|
||||
smoothPitch = pitch
|
||||
hasInitialReading = true
|
||||
} else {
|
||||
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
|
||||
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||
smoothPitch = lowPass(smoothPitch, pitch)
|
||||
}
|
||||
|
||||
_orientation.value = Pair(
|
||||
round(smoothAzimuth * 10) / 10,
|
||||
round(smoothPitch * 10) / 10
|
||||
)
|
||||
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||
}
|
||||
|
||||
/** Standard exponential low-pass filter. */
|
||||
private fun lowPass(previous: Float, current: Float): Float {
|
||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||
}
|
||||
@@ -135,9 +123,10 @@ class SensorsRepo(
|
||||
*/
|
||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||
var delta = current - previous
|
||||
// Normalise delta into the range (-180, 180]
|
||||
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> {
|
||||
val typesString = preferences.getString(keySelectedTypes, null)
|
||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||
if (typesString.isNullOrEmpty()) return emptyList()
|
||||
return typesString.split(separatorComma)
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
look4SatDao.deleteRadios()
|
||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -45,7 +46,10 @@ class RemoteSource(
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
try {
|
||||
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) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
null
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ISensorsRepo {
|
||||
val orientation: StateFlow<Pair<Float, Float>>
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
|
||||
@@ -47,11 +47,12 @@ class SstvDecoder(
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val frames: SharedFlow<SstvFrame> = _frames
|
||||
val supportedModes: List<String> = buildList {
|
||||
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
|
||||
}
|
||||
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||
|
||||
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)
|
||||
if (hasNewLines) emitFrame()
|
||||
}
|
||||
@@ -64,12 +65,31 @@ class SstvDecoder(
|
||||
}
|
||||
|
||||
private fun emitFrame() {
|
||||
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
|
||||
val imageWidth = imageBuffer.width
|
||||
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
|
||||
_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 }
|
||||
@@ -136,6 +156,10 @@ internal class SyncPulseDetector(sampleRate: Int) {
|
||||
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
|
||||
var detected = false
|
||||
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) {
|
||||
when (channelSelect) {
|
||||
1 -> baseBand.set(buffer[2 * i])
|
||||
@@ -200,7 +224,6 @@ internal class DecoderEngine(
|
||||
private val visBitLen: Int
|
||||
private val visLen: Int
|
||||
private val rawMode: SstvMode
|
||||
private val hfFaxMode: SstvMode
|
||||
private val modes5ms: ArrayList<SstvMode>
|
||||
private val modes9ms: ArrayList<SstvMode>
|
||||
private val modes20ms: ArrayList<SstvMode>
|
||||
@@ -217,6 +240,10 @@ internal class DecoderEngine(
|
||||
|
||||
init {
|
||||
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
|
||||
pulseFilterDelay = (pfLen - 1) / 2
|
||||
pulseFilter = MovingAverage(pfLen)
|
||||
@@ -231,7 +258,6 @@ internal class DecoderEngine(
|
||||
syncTolerance = round(0.03 * sampleRate).toInt()
|
||||
lineTolerance = round(0.001 * sampleRate).toInt()
|
||||
rawMode = RawMode(rawName, sampleRate)
|
||||
hfFaxMode = HfFaxMode(sampleRate)
|
||||
val robot36 = Robot36Mode(sampleRate)
|
||||
currentMode = robot36
|
||||
curLineSamples = robot36.scanLineSamples
|
||||
@@ -301,11 +327,7 @@ internal class DecoderEngine(
|
||||
}
|
||||
|
||||
fun setMode(name: String) {
|
||||
if (rawMode.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
|
||||
val mode = allModes.firstOrNull { it.name == name }
|
||||
if (mode == currentMode) {
|
||||
lockMode = true; return
|
||||
}
|
||||
@@ -335,6 +357,10 @@ internal class DecoderEngine(
|
||||
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() {
|
||||
val w = minOf(scopeBuffer.width, pixelBuffer.width)
|
||||
for (row in 0 until pixelBuffer.height) {
|
||||
@@ -424,6 +450,9 @@ internal class DecoderEngine(
|
||||
if ((amount <= 0) || (amount > sample)) return
|
||||
sample -= amount; leaderBreak -= amount; lastSync -= 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)
|
||||
}
|
||||
|
||||
@@ -494,7 +523,7 @@ internal class DecoderEngine(
|
||||
if (lockMode && mode != currentMode) return false
|
||||
mode.resetState()
|
||||
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
|
||||
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
|
||||
var oldest = lastSync - (pulses.size - 1) * curLineSamples
|
||||
@@ -527,6 +556,15 @@ internal class DecoderEngine(
|
||||
var changed = false
|
||||
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
|
||||
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 {
|
||||
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
|
||||
changed =
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
package com.rtbishop.look4sat.core.domain.sstv
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
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 abs(): Float = sqrt(real * real + imag * imag)
|
||||
fun arg(): Float = atan2(imag, real)
|
||||
|
||||
fun mul(other: Complex): Complex {
|
||||
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) {
|
||||
private val tree = FloatArray(2 * length)
|
||||
private var leaf = length
|
||||
private val buf = FloatArray(length)
|
||||
private var pos = 0
|
||||
private var runningSum = 0f
|
||||
|
||||
fun add(input: Float) {
|
||||
tree[leaf] = input
|
||||
var child = leaf
|
||||
var parent = leaf / 2
|
||||
while (parent > 0) {
|
||||
tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
|
||||
}
|
||||
if (++leaf >= tree.size) leaf = length
|
||||
runningSum += input - buf[pos]
|
||||
buf[pos] = input
|
||||
if (++pos >= length) pos = 0
|
||||
}
|
||||
|
||||
fun sum(): Float = tree[1]
|
||||
fun sum(): Float = runningSum
|
||||
fun sum(input: Float): Float {
|
||||
add(input); return sum()
|
||||
}
|
||||
@@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) {
|
||||
val omega = 2 * PI * freq / rate
|
||||
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) {
|
||||
@@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
||||
private var prev = 0f
|
||||
|
||||
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
|
||||
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
|
||||
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) {
|
||||
|
||||
@@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode {
|
||||
val lPos = lumBegin + (i * lumSamples) / width
|
||||
val cPos = chromBegin + (i * chromSamples) / width
|
||||
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])
|
||||
} else {
|
||||
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)
|
||||
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
|
||||
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 {
|
||||
override val visCode = -1
|
||||
override val width = -1
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||
import kotlin.math.acos
|
||||
import kotlin.math.atan2
|
||||
@@ -85,6 +87,13 @@ fun clipLon(longitude: Double): Double {
|
||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||
}
|
||||
|
||||
fun OrbitalPos.toMapGeoPos(): GeoPos {
|
||||
return GeoPos(
|
||||
latitude = clipLat(latitude.toDegrees()),
|
||||
longitude = clipLon(longitude.toDegrees())
|
||||
)
|
||||
}
|
||||
|
||||
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
||||
return min(max(currentValue, minValue), maxValue)
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
* 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 android.graphics.Paint
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.core.graphics.withRotation
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
@Immutable
|
||||
data class BubblesStyle(
|
||||
val bgColor: Color = Color.Black,
|
||||
val bubbleCount: Int = 16,
|
||||
val minBubbleCount: Int = 8,
|
||||
val maxBubbleCount: Int = 16,
|
||||
val adaptiveBubbleCount: Boolean = true,
|
||||
val spawnIntervalMs: Long = 800L,
|
||||
val bubbleRadiusFraction: Float = 0.24f,
|
||||
val adaptiveSizing: Boolean = true,
|
||||
val referenceMinSizePx: Float = 360f,
|
||||
val referenceAreaPx: Float = 360f * 800f,
|
||||
val minBubbleRadiusPx: Float = 16f,
|
||||
val maxBubbleRadiusPx: Float = 256f,
|
||||
val speedScale: Float = 0.99f,
|
||||
val minVelocity: Float = 0.8f,
|
||||
val maxVelocity: Float = 3.2f,
|
||||
val hueRotationSpeedDps: Float = 60f, // degrees per second
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BubblesEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
isRunning: Boolean = true,
|
||||
style: BubblesStyle = BubblesStyle(),
|
||||
) {
|
||||
val renderer = remember { BubblesRenderer() }
|
||||
var frameSignal by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Animation loop
|
||||
LaunchedEffect(isRunning, style) {
|
||||
if (!isRunning) return@LaunchedEffect
|
||||
var previousNanos = 0L
|
||||
while (isActive) {
|
||||
withFrameNanos { now ->
|
||||
if (previousNanos == 0L) previousNanos = now
|
||||
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||
previousNanos = now
|
||||
renderer.update(deltaSec, style)
|
||||
frameSignal++
|
||||
}
|
||||
}
|
||||
}
|
||||
Canvas(modifier = modifier) {
|
||||
frameSignal
|
||||
renderer.ensureLayout(size)
|
||||
renderer.draw(this, style)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS
|
||||
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||
private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f)
|
||||
private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f)
|
||||
|
||||
private data class Velocity(var x: Float, var y: Float)
|
||||
|
||||
private data class Bubble(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
val radius: Float,
|
||||
val velocity: Velocity,
|
||||
val baseHue: Float, // 0-360, unique for each bubble
|
||||
)
|
||||
|
||||
private class BubblesRenderer {
|
||||
private var width = 0f
|
||||
private var height = 0f
|
||||
private val bubbles = mutableListOf<Bubble>()
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
private var globalHueRotation = 0f // degrees, rotates all bubbles hues
|
||||
private var spawnAccumulatorSec = 0f
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val shellColors = IntArray(8)
|
||||
private val innerColors = IntArray(4)
|
||||
|
||||
fun ensureLayout(size: Size) {
|
||||
val targetWidth = size.width
|
||||
val targetHeight = size.height
|
||||
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||
val shouldRebuild = width != targetWidth || height != targetHeight
|
||||
if (shouldRebuild) {
|
||||
width = targetWidth
|
||||
height = targetHeight
|
||||
bubbles.clear()
|
||||
spawnAccumulatorSec = 0f
|
||||
}
|
||||
}
|
||||
|
||||
fun spawnBubble(style: BubblesStyle): Boolean {
|
||||
if (width <= 0f || height <= 0f) return false
|
||||
val radius = resolveBubbleRadius(style)
|
||||
val (velocityMin, velocityMax) = resolveVelocityRange(style)
|
||||
val bubble = Bubble(
|
||||
x = radius,
|
||||
y = height - radius,
|
||||
radius = radius,
|
||||
velocity = Velocity(
|
||||
x = randomInRange(velocityMin, velocityMax),
|
||||
y = -randomInRange(velocityMin, velocityMax),
|
||||
),
|
||||
baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble
|
||||
)
|
||||
bubbles.add(bubble)
|
||||
return true
|
||||
}
|
||||
|
||||
fun update(deltaSeconds: Float, style: BubblesStyle) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
|
||||
spawnMissingBubbles(deltaSeconds, style)
|
||||
|
||||
// Rotate hue for all bubbles
|
||||
globalHueRotation += style.hueRotationSpeedDps * deltaSeconds
|
||||
if (globalHueRotation >= 360f) globalHueRotation -= 360f
|
||||
val frameScale = deltaSeconds * 60f
|
||||
for (i in bubbles.indices) {
|
||||
val bubble = bubbles[i]
|
||||
// Update position
|
||||
bubble.x += bubble.velocity.x * frameScale
|
||||
bubble.y += bubble.velocity.y * frameScale
|
||||
// Bounce off walls
|
||||
if (bubble.x > width - bubble.radius) {
|
||||
bubble.x = width - bubble.radius
|
||||
bubble.velocity.x *= -1
|
||||
}
|
||||
if (bubble.x < bubble.radius) {
|
||||
bubble.x = bubble.radius
|
||||
bubble.velocity.x *= -1
|
||||
}
|
||||
if (bubble.y > height - bubble.radius) {
|
||||
bubble.y = height - bubble.radius
|
||||
bubble.velocity.y *= -1
|
||||
}
|
||||
if (bubble.y < bubble.radius) {
|
||||
bubble.y = bubble.radius
|
||||
bubble.velocity.y *= -1
|
||||
}
|
||||
}
|
||||
// Collision detection and resolution
|
||||
for (i in bubbles.indices) {
|
||||
for (j in (i + 1) until bubbles.size) {
|
||||
val b1 = bubbles[i]
|
||||
val b2 = bubbles[j]
|
||||
if (isCollided(b1, b2)) resolveCollision(b1, b2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) {
|
||||
val targetCount = resolveTargetBubbleCount(style)
|
||||
if (bubbles.size >= targetCount) return
|
||||
|
||||
val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f
|
||||
spawnAccumulatorSec += deltaSeconds
|
||||
|
||||
while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) {
|
||||
if (!spawnBubble(style)) break
|
||||
spawnAccumulatorSec -= spawnIntervalSec
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(scope: DrawScope, style: BubblesStyle) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
scope.drawRect(style.bgColor)
|
||||
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||
for (i in bubbles.indices) {
|
||||
drawBubbleWithGradient(canvas, bubbles[i])
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) {
|
||||
val hue = (bubble.baseHue + globalHueRotation) % 360f
|
||||
val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f)
|
||||
val rgb = hueToRgb(hue, lit)
|
||||
val shellColor = mixWithWhite(rgb, 0.04f)
|
||||
val innerColor = mixWithWhite(rgb, 0.10f)
|
||||
val shimmerColor = mixWithWhite(rgb, 0.35f)
|
||||
// Outer shell
|
||||
val shellGradient = RadialGradient(
|
||||
bubble.x, bubble.y, bubble.radius,
|
||||
buildShellColors(shellColor),
|
||||
SHELL_GRADIENT_STOPS,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.shader = shellGradient
|
||||
canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint)
|
||||
// Subtle thin rim
|
||||
paint.shader = null
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f)
|
||||
paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f)
|
||||
canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint)
|
||||
// Top internal bubble
|
||||
val innerTop = bubble.y - bubble.radius * 0.96f
|
||||
val innerBottom = bubble.y + bubble.radius * 0.48f
|
||||
val innerLeft = bubble.x - bubble.radius * 0.84f
|
||||
val innerRight = bubble.x + bubble.radius * 0.84f
|
||||
val innerGradient = LinearGradient(
|
||||
bubble.x,
|
||||
innerTop,
|
||||
bubble.x,
|
||||
innerBottom,
|
||||
buildInnerColors(innerColor),
|
||||
INNER_GRADIENT_STOPS,
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.shader = innerGradient
|
||||
canvas.drawOval(
|
||||
innerLeft,
|
||||
innerTop,
|
||||
innerRight,
|
||||
innerBottom,
|
||||
paint
|
||||
)
|
||||
// Top-left shimmer
|
||||
paint.shader = null
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = colorWithAlpha(shimmerColor, 0.95f)
|
||||
val shimmerCx = bubble.x - bubble.radius * 0.40f
|
||||
val shimmerCy = bubble.y - bubble.radius * 0.72f
|
||||
val shimmerHalfWidth = bubble.radius * 0.06f
|
||||
val shimmerHalfHeight = bubble.radius * 0.21f
|
||||
canvas.withRotation(60f, shimmerCx, shimmerCy) {
|
||||
drawOval(
|
||||
shimmerCx - shimmerHalfWidth,
|
||||
shimmerCy - shimmerHalfHeight,
|
||||
shimmerCx + shimmerHalfWidth,
|
||||
shimmerCy + shimmerHalfHeight,
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean {
|
||||
val dx = bubble1.x - bubble2.x
|
||||
val dy = bubble1.y - bubble2.y
|
||||
val radius = (bubble1.radius + bubble2.radius) * 0.9f
|
||||
return dx * dx + dy * dy < radius * radius
|
||||
}
|
||||
|
||||
private fun resolveCollision(particle: Bubble, otherParticle: Bubble) {
|
||||
val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x
|
||||
val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y
|
||||
val xDist = otherParticle.x - particle.x
|
||||
val yDist = otherParticle.y - particle.y
|
||||
// Prevent accidental overlap
|
||||
if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {
|
||||
val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x)
|
||||
val m1 = 1f
|
||||
val m2 = 1f
|
||||
val u1 = rotate(particle.velocity, angle)
|
||||
val u2 = rotate(otherParticle.velocity, angle)
|
||||
val v1 = Velocity(
|
||||
x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2),
|
||||
y = u1.y,
|
||||
)
|
||||
val v2 = Velocity(
|
||||
x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2),
|
||||
y = u2.y,
|
||||
)
|
||||
val vFinal1 = rotate(v1, -angle)
|
||||
val vFinal2 = rotate(v2, -angle)
|
||||
particle.velocity.x = vFinal1.x
|
||||
particle.velocity.y = vFinal1.y
|
||||
otherParticle.velocity.x = vFinal2.x
|
||||
otherParticle.velocity.y = vFinal2.y
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotate(velocity: Velocity, angle: Float): Velocity {
|
||||
return Velocity(
|
||||
x = velocity.x * cos(angle) - velocity.y * sin(angle),
|
||||
y = velocity.x * sin(angle) + velocity.y * cos(angle),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildShellColors(shellColor: Int): IntArray {
|
||||
shellColors[0] = colorWithAlpha(shellColor, 0f)
|
||||
shellColors[1] = colorWithAlpha(shellColor, 0f)
|
||||
shellColors[2] = colorWithAlpha(shellColor, 0.04f)
|
||||
shellColors[3] = colorWithAlpha(shellColor, 0.12f)
|
||||
shellColors[4] = colorWithAlpha(shellColor, 0.28f)
|
||||
shellColors[5] = colorWithAlpha(shellColor, 0.44f)
|
||||
shellColors[6] = colorWithAlpha(shellColor, 0.58f)
|
||||
shellColors[7] = colorWithAlpha(shellColor, 0.64f)
|
||||
return shellColors
|
||||
}
|
||||
|
||||
private fun buildInnerColors(innerColor: Int): IntArray {
|
||||
innerColors[0] = colorWithAlpha(innerColor, 0.48f)
|
||||
innerColors[1] = colorWithAlpha(innerColor, 0.36f)
|
||||
innerColors[2] = colorWithAlpha(innerColor, 0.08f)
|
||||
innerColors[3] = colorWithAlpha(innerColor, 0f)
|
||||
return innerColors
|
||||
}
|
||||
|
||||
private fun resolveBubbleRadius(style: BubblesStyle): Float {
|
||||
val minDimension = min(width, height)
|
||||
val baseRadius = minDimension * style.bubbleRadiusFraction
|
||||
val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f)
|
||||
val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx)
|
||||
if (!style.adaptiveSizing || minDimension <= 0f) {
|
||||
return baseRadius.coerceIn(minRadius, maxRadius)
|
||||
}
|
||||
|
||||
val reference = style.referenceMinSizePx.coerceAtLeast(1f)
|
||||
val dampening = sqrt((reference / minDimension).coerceAtMost(1f))
|
||||
return (baseRadius * dampening).coerceIn(minRadius, maxRadius)
|
||||
}
|
||||
|
||||
private fun resolveTargetBubbleCount(style: BubblesStyle): Int {
|
||||
val baseCount = style.bubbleCount.coerceAtLeast(1)
|
||||
if (!style.adaptiveBubbleCount) return baseCount
|
||||
|
||||
val area = width * height
|
||||
val referenceArea = style.referenceAreaPx.coerceAtLeast(1f)
|
||||
val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f))
|
||||
val scaledCount = (baseCount * areaScale).toInt()
|
||||
val minCount = style.minBubbleCount.coerceAtLeast(1)
|
||||
val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1))
|
||||
return scaledCount.coerceIn(minCount, maxCount)
|
||||
}
|
||||
|
||||
private fun resolveVelocityRange(style: BubblesStyle): Pair<Float, Float> {
|
||||
val referenceMaxVelocity = maxOf(height / 300f, 1f)
|
||||
val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f)
|
||||
val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale)
|
||||
return velocityMin to velocityMax
|
||||
}
|
||||
|
||||
private fun randomInRange(min: Float, max: Float): Float {
|
||||
return random.nextFloat() * (max - min) + min
|
||||
}
|
||||
}
|
||||
|
||||
private fun hueToRgb(h: Float, l: Float): Int {
|
||||
val hNorm = h / 360f
|
||||
val lNorm = l / 100f
|
||||
val sNorm = 1f
|
||||
val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm
|
||||
val hp = hNorm * 6f
|
||||
val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f))
|
||||
val m = lNorm - c / 2f
|
||||
val (r, g, b) = when {
|
||||
hp < 1f -> Triple(c, x, 0f)
|
||||
hp < 2f -> Triple(x, c, 0f)
|
||||
hp < 3f -> Triple(0f, c, x)
|
||||
hp < 4f -> Triple(0f, x, c)
|
||||
hp < 5f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
val r8 = ((r + m) * 255).toInt().coerceIn(0, 255)
|
||||
val g8 = ((g + m) * 255).toInt().coerceIn(0, 255)
|
||||
val b8 = ((b + m) * 255).toInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8
|
||||
}
|
||||
|
||||
private fun colorWithAlpha(color: Int, alpha: Float): Int {
|
||||
val a = (alpha * 255).toInt().coerceIn(0, 255)
|
||||
val r = (color shr 16) and 0xFF
|
||||
val g = (color shr 8) and 0xFF
|
||||
val b = color and 0xFF
|
||||
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
private fun mixWithWhite(color: Int, amount: Float): Int {
|
||||
val t = amount.coerceIn(0f, 1f)
|
||||
val r = (color shr 16) and 0xFF
|
||||
val g = (color shr 8) and 0xFF
|
||||
val b = color and 0xFF
|
||||
val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255)
|
||||
val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255)
|
||||
val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedButton
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -49,12 +50,21 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -387,3 +397,76 @@ fun elevationColor(elevation: Double): Color {
|
||||
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)
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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 android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
|
||||
@Immutable
|
||||
data class MatrixStyle(
|
||||
val bgColor: Color = Color.Black,
|
||||
val bodyColor: Color = Color(0xFF29C94A),
|
||||
val headColor: Color = Color(0xFFC2FFC6),
|
||||
val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f,
|
||||
val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp
|
||||
val minStreamLength: Int = 8, // 6 - 8
|
||||
val maxStreamLength: Int = 28, // 20 - 28
|
||||
val minSpeedRps: Float = 10f, // 10f - 15f
|
||||
val maxSpeedRps: Float = 38f, // 38f - 45f
|
||||
val resetPauseSec: ClosedFloatingPointRange<Float> = 0.1f..1.0f,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MatrixEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
isRunning: Boolean = true,
|
||||
style: MatrixStyle = MatrixStyle(),
|
||||
symbols: String = DEFAULT_SYMBOLS,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val renderer = remember { MatrixRenderer() }
|
||||
var frameSignal by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(isRunning, style, symbols) {
|
||||
if (!isRunning) return@LaunchedEffect
|
||||
|
||||
var previousNanos = 0L
|
||||
while (isActive) {
|
||||
withFrameNanos { now ->
|
||||
if (previousNanos == 0L) previousNanos = now
|
||||
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||
previousNanos = now
|
||||
renderer.update(deltaSec, style)
|
||||
frameSignal++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
frameSignal
|
||||
renderer.ensureLayout(size, density.density, style, symbols)
|
||||
renderer.draw(this, style)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_STEP_NANOS = 33_333_333L
|
||||
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||
private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()"
|
||||
|
||||
private fun Color.toArgb(): Int {
|
||||
val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
private class MatrixRenderer {
|
||||
private var columns = 0
|
||||
private var rows = 0
|
||||
private var width = 0f
|
||||
private var height = 0f
|
||||
private var fontSizePx = 0f
|
||||
private var charWidth = 0f
|
||||
private var charHeight = 0f
|
||||
private var baselineOffset = 0f
|
||||
private var symbolSet = ""
|
||||
private var symbols = charArrayOf()
|
||||
|
||||
private var glyphs = charArrayOf()
|
||||
private var streams = emptyArray<StreamState>()
|
||||
|
||||
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||
private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||
private val charBuffer = CharArray(1)
|
||||
private val random = Random(System.currentTimeMillis())
|
||||
|
||||
fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) {
|
||||
val targetFontPx = style.fontSize.value * density
|
||||
val targetWidth = size.width
|
||||
val targetHeight = size.height
|
||||
val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS }
|
||||
|
||||
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||
|
||||
val shouldRebuild = width != targetWidth ||
|
||||
height != targetHeight ||
|
||||
fontSizePx != targetFontPx ||
|
||||
symbolSet != targetSymbols
|
||||
|
||||
if (!shouldRebuild) return
|
||||
|
||||
width = targetWidth
|
||||
height = targetHeight
|
||||
fontSizePx = targetFontPx
|
||||
symbolSet = targetSymbols
|
||||
this.symbols = targetSymbols.toCharArray()
|
||||
|
||||
bodyPaint.textSize = targetFontPx
|
||||
headPaint.textSize = targetFontPx
|
||||
charWidth = max(bodyPaint.measureText("W"), 1f)
|
||||
val metrics = bodyPaint.fontMetrics
|
||||
charHeight = max(metrics.descent - metrics.ascent, 1f)
|
||||
baselineOffset = -metrics.ascent
|
||||
|
||||
columns = max((width / charWidth).toInt(), 1)
|
||||
rows = max((height / charHeight).toInt() + 2, 1)
|
||||
|
||||
glyphs = CharArray(columns * rows) { randomGlyph() }
|
||||
streams = Array(columns) { StreamState.random(rows, style, random) }
|
||||
}
|
||||
|
||||
fun update(deltaSeconds: Float, style: MatrixStyle) {
|
||||
if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return
|
||||
|
||||
for (column in streams.indices) {
|
||||
val stream = streams[column]
|
||||
stream.pauseSec -= deltaSeconds
|
||||
if (stream.pauseSec > 0f) continue
|
||||
|
||||
val previousHead = floor(stream.headRow).toInt()
|
||||
stream.headRow += stream.speedRps * deltaSeconds
|
||||
val newHead = floor(stream.headRow).toInt()
|
||||
|
||||
if (newHead > previousHead) {
|
||||
for (row in (previousHead + 1)..newHead) {
|
||||
if (row in 0 until rows) {
|
||||
glyphs[row * columns + column] = randomGlyph()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newHead - stream.length > rows) {
|
||||
stream.reset(rows, style, random)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) {
|
||||
if (columns == 0 || rows == 0) return
|
||||
|
||||
scope.drawRect(style.bgColor)
|
||||
|
||||
bodyPaint.color = style.bodyColor.toArgb()
|
||||
headPaint.color = style.headColor.toArgb()
|
||||
val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f)
|
||||
|
||||
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||
for (column in streams.indices) {
|
||||
val stream = streams[column]
|
||||
if (stream.pauseSec > 0f) continue
|
||||
|
||||
val head = floor(stream.headRow).toInt()
|
||||
val startRow = max(0, head - stream.length + 1)
|
||||
val endRow = min(rows - 1, head)
|
||||
if (startRow > endRow) continue
|
||||
|
||||
for (row in endRow downTo startRow) {
|
||||
val tailIndex = head - row
|
||||
|
||||
val paint = if (tailIndex == 0) headPaint else bodyPaint
|
||||
if (tailIndex != 0) {
|
||||
val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f)
|
||||
val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized
|
||||
paint.alpha = (alpha * 255).toInt()
|
||||
} else {
|
||||
paint.alpha = 255
|
||||
}
|
||||
|
||||
val glyph = glyphs[row * columns + column]
|
||||
charBuffer[0] = glyph
|
||||
val x = column * charWidth
|
||||
val y = row * charHeight + baselineOffset
|
||||
canvas.drawText(charBuffer, 0, 1, x, y, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)]
|
||||
}
|
||||
|
||||
private data class StreamState(
|
||||
var headRow: Float,
|
||||
var length: Int,
|
||||
var speedRps: Float,
|
||||
var pauseSec: Float,
|
||||
) {
|
||||
fun reset(rows: Int, style: MatrixStyle, random: Random) {
|
||||
val randomOffset = random.nextFloat() * rows
|
||||
headRow = -randomOffset
|
||||
length = random.nextInt(
|
||||
from = style.minStreamLength.coerceAtLeast(2),
|
||||
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||
)
|
||||
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps
|
||||
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||
style.resetPauseSec.start
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun random(rows: Int, style: MatrixStyle, random: Random): StreamState {
|
||||
val length = random.nextInt(
|
||||
from = style.minStreamLength.coerceAtLeast(2),
|
||||
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||
)
|
||||
return StreamState(
|
||||
headRow = -random.nextFloat() * rows,
|
||||
length = length,
|
||||
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps,
|
||||
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||
style.resetPauseSec.start,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,33 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
@Serializable
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||
|
||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
<string name="prefs_bt_frequency_device_hint">蓝牙设备 ID</string>
|
||||
<string name="prefs_bt_frequency_output_hint">数据格式</string>
|
||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
|
||||
@@ -49,9 +49,8 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Merged RadarScreen and RadioControlScreen functionality
|
||||
\n\n* Added SSTV image decoding functionality to RadarScreen
|
||||
\n\n* Added colored elevation and decay check to satellite passes
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
@@ -174,6 +173,7 @@
|
||||
<string name="prefs_bt_frequency_device_hint">Device id</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Data format</string>
|
||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||
|
||||
<string name="prefs_other_title">Other settings</string>
|
||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||
|
||||
+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,2 @@
|
||||
* Merged RadarScreen and RadioControlScreen functionality
|
||||
* Added SSTV image decoding functionality to RadarScreen
|
||||
* Added colored elevation and decay check to satellite passes
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.clipLat
|
||||
import com.rtbishop.look4sat.core.domain.utility.clipLon
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
@@ -166,9 +167,7 @@ class MapViewModel(
|
||||
var localSelectedPos: OrbitalPos? = null
|
||||
for (satellite in chunk) {
|
||||
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||
localPositions.add(satellite to GeoPos(osmLat, osmLon))
|
||||
localPositions.add(satellite to satPos.toMapGeoPos())
|
||||
if (satellite === selected) {
|
||||
localSelectedPos = satPos
|
||||
}
|
||||
@@ -242,9 +241,7 @@ class MapViewModel(
|
||||
}
|
||||
val azimuth = satPos.azimuth.toDegrees()
|
||||
val elevation = satPos.elevation.toDegrees()
|
||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||
val osmPos = GeoPos(osmLat, osmLon)
|
||||
val osmPos = satPos.toMapGeoPos()
|
||||
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
||||
val velocity = satPos.getOrbitalVelocity()
|
||||
val phase = satPos.phase.toDegrees()
|
||||
@@ -274,18 +271,16 @@ class MapViewModel(
|
||||
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
||||
var oldLongitude = 0.0
|
||||
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||
val currentPosition = GeoPos(osmLat, osmLon)
|
||||
val currentPosition = satPos.toMapGeoPos()
|
||||
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
||||
// adding left terminal position
|
||||
currentTrack.add(GeoPos(osmLat, -180.0))
|
||||
currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
|
||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||
satTracks.add(finishedTrack)
|
||||
currentTrack.clear()
|
||||
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
||||
// adding right terminal position
|
||||
currentTrack.add(GeoPos(osmLat, 180.0))
|
||||
currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
|
||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||
satTracks.add(finishedTrack)
|
||||
currentTrack.clear()
|
||||
|
||||
@@ -66,6 +66,7 @@ import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
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.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
@@ -170,13 +171,14 @@ private fun PagerCard(
|
||||
) { pageIndex ->
|
||||
when (pages[pageIndex]) {
|
||||
RadarPage.Transceivers -> TransceiversPage(
|
||||
transceivers = uiState.transmitters,
|
||||
selectedUuid = uiState.selectedTransmitterUuid,
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
@@ -189,25 +191,26 @@ private fun PagerCard(
|
||||
@Composable
|
||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||
val satellitePos = uiState.orbitalPos
|
||||
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
||||
val borderAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1.0f,
|
||||
targetValue = 0.0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "eclipsedBorderAlpha"
|
||||
)
|
||||
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 borderAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1.0f,
|
||||
targetValue = 0.0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "eclipsedBorderAlpha"
|
||||
)
|
||||
val borderModifier = if (shouldAnimateBorder) {
|
||||
Modifier.border(
|
||||
width = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
} else Modifier
|
||||
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val position = uiState.orbitalPos
|
||||
@@ -294,4 +297,3 @@ private fun RadarLabel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ data class RadioPanelState(
|
||||
data class RadioControlSubState(
|
||||
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
||||
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
||||
val transponders: List<SatRadio> = emptyList(),
|
||||
val selectedTransponderUuid: String? = null,
|
||||
val txBaseFrequencyHz: Long? = null,
|
||||
val ctcssTone: Double? = null,
|
||||
@@ -42,11 +41,16 @@ data class RadioControlSubState(
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
data class TransceiverSubState(
|
||||
val transmitters: List<SatRadio> = emptyList(),
|
||||
val selectedUuid: String? = null,
|
||||
val selectedFrequency: Long? = null,
|
||||
)
|
||||
|
||||
data class RadarState(
|
||||
val currentPass: OrbitalPass? = null,
|
||||
val currentTime: String = "00:00:00",
|
||||
val isTimeAos: Boolean = true,
|
||||
val isLos: Boolean = false,
|
||||
val isUtc: Boolean = false,
|
||||
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
||||
val orbitalPos: OrbitalPos? = null,
|
||||
@@ -55,17 +59,16 @@ data class RadarState(
|
||||
val shouldUseCompass: Boolean = false,
|
||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||
val transmitters: List<SatRadio> = emptyList(),
|
||||
val selectedTransmitterUuid: String? = null,
|
||||
val selectedFrequency: Long? = null,
|
||||
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||
val sstv: SstvSubState = SstvSubState()
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording, Saving }
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
|
||||
data class SstvSubState(
|
||||
val status: SstvStatus = SstvStatus.Idle,
|
||||
val isSaving: Boolean = false,
|
||||
val hasPermission: Boolean = false,
|
||||
val selectedMode: String = "Auto",
|
||||
val supportedModes: List<String> = emptyList(),
|
||||
@@ -77,7 +80,6 @@ sealed interface RadarAction {
|
||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||
|
||||
// Radio control actions
|
||||
data class SelectTransponder(val uuid: String) : RadarAction
|
||||
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
||||
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
||||
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
@@ -64,7 +65,7 @@ import kotlin.math.sin
|
||||
|
||||
private const val CIRCLES = 3
|
||||
private const val STROKE_WIDTH = 6f
|
||||
private const val SWEEP_INCREMENT = 360f / 12f / 60f
|
||||
private const val SWEEP_DURATION_MS = 8_000
|
||||
|
||||
@Composable
|
||||
fun RadarViewCompose(
|
||||
@@ -88,23 +89,44 @@ fun RadarViewCompose(
|
||||
animationSpec = infiniteRepeatable(tween(1000)),
|
||||
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 sunPainter = painterResource(R.drawable.ic_sun)
|
||||
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 cachedItemsRef by remember { mutableStateOf<List<OrbitalPos>>(emptyList()) }
|
||||
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
||||
var trackPath by remember { mutableStateOf(Path()) }
|
||||
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)) {
|
||||
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)
|
||||
trackEffect = createTrackEffect(trackPath)
|
||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||
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) {
|
||||
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor)
|
||||
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||
drawRadar(radius, radarColor)
|
||||
drawElevationLabels(radius, primaryColor, measurer)
|
||||
translate(center.x, center.y) {
|
||||
@@ -127,7 +149,6 @@ fun RadarViewCompose(
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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 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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -48,7 +49,7 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class RadarViewModel(
|
||||
private val bluetoothReporter: IReporter,
|
||||
@@ -69,10 +70,15 @@ class RadarViewModel(
|
||||
private var sstvDecoder: SstvDecoder? = 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(
|
||||
RadarState(
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
orientationValues = sensorsRepo.orientation.value,
|
||||
orientationValues = sensorsRepo.sensorData.value,
|
||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||
@@ -91,7 +97,7 @@ class RadarViewModel(
|
||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsRepo.orientation.collect { data ->
|
||||
sensorsRepo.sensorData.collect { data ->
|
||||
val orientationValues = (data.first + magDeclination) to data.second
|
||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||
}
|
||||
@@ -101,52 +107,75 @@ class RadarViewModel(
|
||||
private fun collectSettingsChanges() {
|
||||
viewModelScope.launch {
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
val passes = satelliteRepo.passes.value
|
||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
|
||||
_uiState.update { it.copy(currentPass = pass) }
|
||||
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
|
||||
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
|
||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
if (!pass.isDeepSpace) {
|
||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||
_uiState.update { it.copy(satTrack = track) }
|
||||
}
|
||||
val pass = findCurrentPass() ?: return@launch
|
||||
val allRadios = loadPassData(pass)
|
||||
while (isActive) {
|
||||
tickPass(pass, transmittersList)
|
||||
delay(1000)
|
||||
tickPass(pass, allRadios)
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) {
|
||||
private fun findCurrentPass(): OrbitalPass? {
|
||||
val passes = satelliteRepo.passes.value
|
||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||
?: passes.firstOrNull()
|
||||
}
|
||||
|
||||
// Loads transmitters and satellite track for pass, sets initial state, returns full radio list
|
||||
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) }
|
||||
}
|
||||
if (!pass.isDeepSpace) {
|
||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||
_uiState.update { it.copy(satTrack = track) }
|
||||
}
|
||||
return allRadios
|
||||
}
|
||||
|
||||
// --- Per-second tick ---
|
||||
|
||||
private suspend fun tickPass(pass: OrbitalPass, allRadios: List<SatRadio>) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
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 isLos = !pass.isDeepSpace && timeNow > pass.losTime
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentTime = time, isTimeAos = isAos, isLos = isLos,
|
||||
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos
|
||||
currentTime = time, isTimeAos = isAos,
|
||||
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||
)
|
||||
}
|
||||
processRadios(transmittersList, pass.orbitalObject, timeNow)
|
||||
processRadios(allRadios, pass.orbitalObject, timeNow)
|
||||
sendPassData(pos)
|
||||
}
|
||||
|
||||
@@ -191,24 +220,15 @@ class RadarViewModel(
|
||||
when (action) {
|
||||
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
||||
is RadarAction.SelectTransmitter -> {
|
||||
val previousUuid = _uiState.value.selectedTransmitterUuid
|
||||
_uiState.update {
|
||||
val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid
|
||||
it.copy(selectedTransmitterUuid = newUuid)
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||
// 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.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||
@@ -236,10 +256,10 @@ class RadarViewModel(
|
||||
RadarAction.SstvSaveImage -> {
|
||||
val frame = _uiState.value.sstv.currentFrame ?: 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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -249,7 +269,7 @@ class RadarViewModel(
|
||||
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
||||
}
|
||||
RadarAction.SstvReset -> {
|
||||
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
|
||||
sstvDecoder?.clearPixels()
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||
}
|
||||
}
|
||||
@@ -285,13 +305,14 @@ class RadarViewModel(
|
||||
frequencyEnabled: Boolean,
|
||||
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 elevation = orbitalPos.elevation.toDegrees().round(2)
|
||||
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
||||
}
|
||||
if (frequencyEnabled) {
|
||||
_uiState.value.selectedFrequency?.let { freq ->
|
||||
_uiState.value.transceivers.selectedFrequency?.let { freq ->
|
||||
reporter.reportFrequency(frequencyFormat, freq)
|
||||
}
|
||||
}
|
||||
@@ -299,12 +320,9 @@ class RadarViewModel(
|
||||
|
||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
||||
val isFreqEnabled =
|
||||
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
||||
_uiState.update { state ->
|
||||
// Derive the frequency to report from the user's current selection (if any)
|
||||
val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) {
|
||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid }
|
||||
val freq = if (state.transceivers.selectedUuid != null) {
|
||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||
selectedRadio?.let { radio ->
|
||||
val low = radio.downlinkLow
|
||||
val high = radio.downlinkHigh
|
||||
@@ -316,10 +334,11 @@ class RadarViewModel(
|
||||
}
|
||||
} else null
|
||||
|
||||
if (state.transmitters == transmitters && state.selectedFrequency == freq) {
|
||||
val current = state.transceivers
|
||||
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
|
||||
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) {
|
||||
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
|
||||
sstvDecoder = decoder
|
||||
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
||||
viewModelScope.launch {
|
||||
decoder.frames.collect { frame ->
|
||||
@@ -362,13 +382,6 @@ class RadarViewModel(
|
||||
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 {
|
||||
initializer {
|
||||
|
||||
@@ -55,12 +55,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
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.infiniteMarquee
|
||||
|
||||
@Composable
|
||||
internal fun SstvPage(
|
||||
sstv: SstvSubState,
|
||||
dopplerFrequency: String?,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
@@ -180,59 +182,72 @@ internal fun SstvPage(
|
||||
)
|
||||
}
|
||||
// Bottom control bar — dark, blends with the black canvas
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// Mode button — opens dialog
|
||||
ElevatedCard(
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
onClick = { showModeDialog.value = true },
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
// 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()
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Mode: ${sstv.selectedMode}",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.infiniteMarquee()
|
||||
// Mode button — opens dialog
|
||||
ElevatedCard(
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
onClick = { showModeDialog.value = true },
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Mode: ${sstv.selectedMode}",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.infiniteMarquee()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset button — clears decoder state and image
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvReset) },
|
||||
resId = R.drawable.ic_delete
|
||||
)
|
||||
// Save button — always visible, enabled when there are pixels
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvSaveImage) },
|
||||
resId = R.drawable.ic_save,
|
||||
enabled = sstv.currentFrame?.imagePixels != null
|
||||
)
|
||||
// Record / Stop button
|
||||
val playAction = {
|
||||
when (sstv.status) {
|
||||
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
||||
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
||||
SstvStatus.Saving -> {}
|
||||
// Reset button — clears decoder state and image
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvReset) },
|
||||
resId = R.drawable.ic_delete
|
||||
)
|
||||
// Save button — always visible, enabled when there are pixels
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvSaveImage) },
|
||||
resId = R.drawable.ic_save,
|
||||
enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
|
||||
)
|
||||
// Record / Stop button
|
||||
val playAction = {
|
||||
when (sstv.status) {
|
||||
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
||||
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
||||
}
|
||||
}
|
||||
}
|
||||
val playColors = CardDefaults.elevatedCardColors(
|
||||
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(playIcon), contentDescription = null)
|
||||
val playColors = CardDefaults.elevatedCardColors(
|
||||
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(playIcon), contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,10 @@ import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun TransceiversPage(
|
||||
@@ -82,7 +84,7 @@ fun TransceiversPage(
|
||||
if (selectedUuid != null) {
|
||||
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
||||
if (index >= 0) {
|
||||
kotlinx.coroutines.delay(300)
|
||||
kotlinx.coroutines.delay(300.milliseconds)
|
||||
listState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
@@ -325,7 +327,7 @@ private fun ExpandedRadioControl(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
||||
text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -335,7 +337,7 @@ private fun ExpandedRadioControl(
|
||||
val upHigh = radio.uplinkHigh
|
||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||
Text(
|
||||
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
|
||||
text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
+21
-5
@@ -91,7 +91,8 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
val dialogs = rememberDialogVisibility()
|
||||
val permissions = rememberSettingsPermissions(
|
||||
sendAction = onAction,
|
||||
onBluetoothGranted = { dialogs.bluetooth = true }
|
||||
onBluetoothGranted = { dialogs.bluetooth = true },
|
||||
onNetworkGranted = { dialogs.network = true }
|
||||
)
|
||||
|
||||
// Dialogs
|
||||
@@ -273,7 +274,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
OutputCard(
|
||||
onNetworkClick = { dialogs.network = true },
|
||||
onNetworkClick = permissions.launchNetwork,
|
||||
onBluetoothClick = permissions.launchBluetooth,
|
||||
onRadioControlClick = { dialogs.radioControl = true }
|
||||
)
|
||||
@@ -651,13 +652,15 @@ private class SettingsPermissions(
|
||||
val launchLocation: () -> Unit,
|
||||
val launchTleImport: () -> Unit,
|
||||
val launchTransceiverImport: () -> Unit,
|
||||
val launchBluetooth: () -> Unit
|
||||
val launchBluetooth: () -> Unit,
|
||||
val launchNetwork: () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rememberSettingsPermissions(
|
||||
sendAction: (SettingsAction) -> Unit,
|
||||
onBluetoothGranted: () -> Unit
|
||||
onBluetoothGranted: () -> Unit,
|
||||
onNetworkGranted: () -> Unit
|
||||
): SettingsPermissions {
|
||||
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
||||
val locationRequest = rememberLauncherForActivityResult(
|
||||
@@ -685,6 +688,12 @@ private fun rememberSettingsPermissions(
|
||||
else sendAction(SettingsAction.ShowToast(bluetoothError))
|
||||
}
|
||||
|
||||
val networkError = stringResource(R.string.prefs_net_perm_error)
|
||||
val networkRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) onNetworkGranted()
|
||||
else sendAction(SettingsAction.ShowToast(networkError))
|
||||
}
|
||||
|
||||
return remember {
|
||||
SettingsPermissions(
|
||||
launchLocation = {
|
||||
@@ -694,7 +703,14 @@ private fun rememberSettingsPermissions(
|
||||
},
|
||||
launchTleImport = { tleRequest.launch("*/*") },
|
||||
launchTransceiverImport = { transceiversRequest.launch("*/*") },
|
||||
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) }
|
||||
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) },
|
||||
launchNetwork = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
networkRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
} else {
|
||||
onNetworkGranted()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -1,35 +1,35 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "440"
|
||||
appVersionCode = "443"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.0"
|
||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
||||
compileSdk = "36"
|
||||
appVersionName = "4.4.3"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
minSdk = "24"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
jdkVersion = "17"
|
||||
jdkVersion = "21"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
packageName = "com.rtbishop.look4sat"
|
||||
|
||||
android-gradle-plugin = "9.2.1"
|
||||
|
||||
androidx-core-ktx = "1.18.0"
|
||||
androidx-core-ktx = "1.19.0"
|
||||
androidx-core-splashscreen = "1.2.0"
|
||||
androidx-room = "2.8.4"
|
||||
|
||||
compose-bom = "2026.05.01"
|
||||
compose-bom = "2026.06.00"
|
||||
compose-activity = "1.13.0"
|
||||
compose-lifecycle = "2.10.0"
|
||||
compose-navigation3 = "1.1.2"
|
||||
compose-lifecycle = "2.11.0"
|
||||
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-serialization = "1.11.0"
|
||||
|
||||
other-okhttp = "5.3.2"
|
||||
other-okhttp = "5.4.0"
|
||||
other-osmdroid = "6.1.20"
|
||||
|
||||
test-junit4 = "4.13.2"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
#Mon May 25 11:20:48 BST 2026
|
||||
#Sat Jun 27 12:51:12 BST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in new issue
Block a user