mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6fb5eb8f | ||
|
|
f4aef4f4f0 | ||
|
|
9147323db2 | ||
|
|
81d397a09d | ||
|
|
ec1a55c93d | ||
|
|
e5033cb2e9 | ||
|
|
39b786ae82 | ||
|
|
79809aaec0 | ||
|
|
f7f5a73c4c | ||
|
|
18c8ac1822 | ||
|
|
22607e7710 | ||
|
|
ad0ff6859e | ||
|
|
a39b1716e7 | ||
|
|
8a2339cfbf |
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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -25,50 +25,55 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Assemble Artifacts
|
- name: Assemble Artifacts
|
||||||
run: |
|
run: ./gradlew assembleRelease bundleRelease
|
||||||
./gradlew assembleRelease
|
|
||||||
./gradlew bundleRelease
|
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
uses: r0adkll/sign-android-release@v1
|
run: |
|
||||||
id: sign_apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
with:
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
releaseDirectory: app/build/outputs/apk/release
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
${BUILD_TOOLS}apksigner sign \
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
--ks keystore.jks \
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
env:
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
--out app/build/outputs/apk/release/look4sat.apk \
|
||||||
|
"$APK"
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Sign Bundle
|
- name: Sign Bundle
|
||||||
uses: r0adkll/sign-android-release@v1
|
|
||||||
id: sign_bundle
|
|
||||||
with:
|
|
||||||
releaseDirectory: app/build/outputs/bundle/release
|
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
env:
|
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
|
||||||
|
|
||||||
- name: Rename Artifacts
|
|
||||||
run: |
|
run: |
|
||||||
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||||
|
-keystore keystore.jks \
|
||||||
|
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
|
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||||
|
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Deploy Bundle
|
- name: Setup Ruby
|
||||||
uses: r0adkll/upload-google-play@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
|
ruby-version: '3.3'
|
||||||
packageName: com.rtbishop.look4sat
|
|
||||||
releaseFiles: app/build/outputs/apk/release/look4sat.aab
|
- name: Deploy Bundle to Google Play
|
||||||
track: production
|
run: |
|
||||||
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
|
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
|
||||||
|
fastlane supply \
|
||||||
|
--aab "$AAB" \
|
||||||
|
--json_key service_account.json \
|
||||||
|
--package_name com.rtbishop.look4sat \
|
||||||
|
--track production \
|
||||||
|
--skip_upload_images true \
|
||||||
|
--skip_upload_screenshots true \
|
||||||
|
rm service_account.json
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Look4Sat AI Agent Instructions
|
||||||
|
|
||||||
|
This is the canonical project guide for all AI assistants working on Look4Sat.
|
||||||
|
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
|
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||||
|
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||||
|
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||||
|
data download.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
|
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||||
|
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||||
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
|
**Clean Architecture layers:**
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|----------------------|---------------------------------------------------------------------|
|
||||||
|
| `app` | Entry point. Aggregates all modules |
|
||||||
|
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||||
|
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||||
|
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||||
|
| `feature:map` | OSMDroid map with ground tracks |
|
||||||
|
| `feature:passes` | Pass predictions and upcoming events |
|
||||||
|
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
||||||
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
|
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Debug build
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Release build (minified, shrunk resources)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
|
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||||
|
|
||||||
|
## Key Libraries
|
||||||
|
|
||||||
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
|
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||||
|
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||||
|
- **OkHttp** 5.x for TLE downloads
|
||||||
|
- **OSMDroid** for map rendering
|
||||||
|
- **Kotlin Serialization** for navigation args and data parsing
|
||||||
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||||
|
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||||
|
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||||
|
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||||
|
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||||
|
|
||||||
|
## Data Formats & Migration
|
||||||
|
|
||||||
|
**TLE vs. OMM/CSV format:**
|
||||||
|
|
||||||
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||||
|
|
||||||
|
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||||
|
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
||||||
|
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
||||||
|
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
||||||
|
|
||||||
|
**Current implementation:**
|
||||||
|
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||||
|
- TLE data is downloaded from configured sources and stored in Room database
|
||||||
|
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||||
|
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||||
|
|
||||||
|
**Migration path:**
|
||||||
|
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||||
|
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||||
|
transition to OMM/CSV automatically.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||||
|
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||||
|
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
|
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||||
|
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||||
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
|
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||||
|
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then follow the instructions there.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -11,12 +11,15 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -26,6 +29,14 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- <intent-filter android:autoVerify="true">-->
|
||||||
|
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||||
|
<!-- <data android:scheme="https" />-->
|
||||||
|
<!-- <data android:host="github.com" />-->
|
||||||
|
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -32,6 +36,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -39,11 +44,13 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -62,25 +69,60 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.radiocontrol.RadioControlDestination
|
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen() {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
LaunchedEffect(deeplink) {
|
||||||
|
deeplink?.let {
|
||||||
|
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||||
|
rootBackStack.add(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
|
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||||
|
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||||
|
NavDisplay(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
backStack = rootBackStack,
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { slideInTransition },
|
||||||
|
popTransitionSpec = { slideOutTransition },
|
||||||
|
predictivePopTransitionSpec = { slideOutTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
innerPadding.calculateTopPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) }
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||||
// 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
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
@@ -138,25 +180,13 @@ fun MainScreen() {
|
|||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> {
|
||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination { catNum, aosTime ->
|
||||||
backStack.add(Screen.Radar(catNum, aosTime))
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
// navigateToRadar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry<Screen.Radar> { route ->
|
entry<Screen.Radar> {
|
||||||
RadarDestination(
|
RadarDestination(navigateUp = navigateBack)
|
||||||
catNum = route.catNum,
|
|
||||||
aosTime = route.aosTime,
|
|
||||||
navigateUp = navigateBack,
|
|
||||||
navigateToRadioControl = { catNum, aosTime ->
|
|
||||||
backStack.add(Screen.RadioControl(catNum, aosTime))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entry<Screen.RadioControl> { route ->
|
|
||||||
RadioControlDestination(
|
|
||||||
catNum = route.catNum,
|
|
||||||
aosTime = route.aosTime,
|
|
||||||
navigateUp = navigateBack
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Map> {
|
entry<Screen.Map> {
|
||||||
MapDestination()
|
MapDestination()
|
||||||
@@ -184,7 +214,8 @@ fun MainScreen() {
|
|||||||
.clickable {
|
.clickable {
|
||||||
val pass = trackingState.currentPass
|
val pass = trackingState.currentPass
|
||||||
if (pass != null) {
|
if (pass != null) {
|
||||||
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime))
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
|||||||
-1
@@ -34,7 +34,6 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:radiocontrol"))
|
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
description = "Cleans the build directory"
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,6 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
+1
-1
@@ -64,7 +64,7 @@ class RadioTrackingService(
|
|||||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") }
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.injection
|
package com.rtbishop.look4sat.core.data.injection
|
||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.Sensor
|
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
@@ -20,6 +36,8 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
|||||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||||
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
@@ -33,6 +51,8 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
@@ -59,6 +79,10 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||||
|
|
||||||
|
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||||
|
|
||||||
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
override fun provideBluetoothReporter(): IReporter {
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val rc = settingsRepo.rcSettings.value
|
val rc = settingsRepo.rcSettings.value
|
||||||
@@ -95,9 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
return SensorsRepo(manager, displayManager)
|
||||||
return SensorsRepo(manager,sensor,window)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
|
|||||||
+14
-6
@@ -52,6 +52,13 @@ class SatelliteRepo(
|
|||||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||||
|
|
||||||
|
private val _selectedPass = MutableStateFlow(0 to 0L)
|
||||||
|
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
|
||||||
|
|
||||||
|
override fun selectPass(catNum: Int, aosTime: Long) {
|
||||||
|
_selectedPass.value = catNum to aosTime
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
@@ -139,17 +146,18 @@ class SatelliteRepo(
|
|||||||
val passes = mutableListOf<OrbitalPass>()
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
val endDate = time + hours * 60L * 60L * 1000L
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
||||||
|
val decayed = this.data.hasDecayed(time)
|
||||||
var startDate = time
|
var startDate = time
|
||||||
var shouldRewind = true
|
var shouldRewind = true
|
||||||
var lastAosDate: Long
|
var lastAosDate: Long
|
||||||
var count = 0
|
var count = 0
|
||||||
if (this.willBeSeen(pos)) {
|
if (this.willBeSeen(pos)) {
|
||||||
if (this.data.isDeepSpace) {
|
if (this.data.isDeepSpace) {
|
||||||
passes.add(getGeoPass(this, pos, time))
|
passes.add(getGeoPass(this, pos, time, decayed))
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
if (count > 0) shouldRewind = false
|
if (count > 0) shouldRewind = false
|
||||||
val pass = getLeoPass(this, pos, startDate, shouldRewind)
|
val pass = getLeoPass(this, pos, startDate, shouldRewind, decayed)
|
||||||
lastAosDate = pass.aosTime
|
lastAosDate = pass.aosTime
|
||||||
passes.add(pass)
|
passes.add(pass)
|
||||||
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
||||||
@@ -160,17 +168,17 @@ class SatelliteRepo(
|
|||||||
return passes
|
return passes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
|
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
|
||||||
val satPos = sat.getPosition(pos, time)
|
val satPos = sat.getPosition(pos, time)
|
||||||
val aos = time - 24 * 60L * 60L * 1000L
|
val aos = time - 24 * 60L * 60L * 1000L
|
||||||
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
||||||
val az = satPos.azimuth.toDegrees().round(1)
|
val az = satPos.azimuth.toDegrees().round(1)
|
||||||
val elev = satPos.elevation.toDegrees().round(1)
|
val elev = satPos.elevation.toDegrees().round(1)
|
||||||
val alt = satPos.altitude
|
val alt = satPos.altitude
|
||||||
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat)
|
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean): OrbitalPass {
|
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean, decayed: Boolean): OrbitalPass {
|
||||||
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
||||||
var calendarTimeMillis = time
|
var calendarTimeMillis = time
|
||||||
var elevation: Double
|
var elevation: Double
|
||||||
@@ -234,6 +242,6 @@ class SatelliteRepo(
|
|||||||
val alt = tcaPos.altitude
|
val alt = tcaPos.altitude
|
||||||
|
|
||||||
val elev = maxElevation.toDegrees().round(1)
|
val elev = maxElevation.toDegrees().round(1)
|
||||||
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
|
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,9 @@ import android.hardware.Sensor
|
|||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.WindowManager
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
|
||||||
|
private const val SMOOTHING_FACTOR = 0.15f
|
||||||
|
private const val SENSOR_RATE_US = 16_000
|
||||||
|
|
||||||
class SensorsRepo(
|
class SensorsRepo(
|
||||||
private val sensorManager: SensorManager,
|
private val sensorManager: SensorManager,
|
||||||
private val sensor: Sensor?,
|
private val displayManager: DisplayManager?
|
||||||
private val windowManager: WindowManager
|
) : ISensorsRepo, SensorEventListener {
|
||||||
) : SensorEventListener, ISensorsRepo {
|
|
||||||
|
|
||||||
private val _orientation = MutableStateFlow(Pair(0f, 0f))
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
|
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
private val rotationMatrix = FloatArray(9)
|
private val rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -45,11 +49,7 @@ class SensorsRepo(
|
|||||||
private var smoothPitch = 0f
|
private var smoothPitch = 0f
|
||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
companion object {
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
private const val SMOOTHING_FACTOR = 0.15f
|
|
||||||
}
|
|
||||||
|
|
||||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -62,7 +62,7 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
hasInitialReading = false
|
||||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
@@ -70,16 +70,11 @@ class SensorsRepo(
|
|||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
if (event.sensor == sensor) updateOrientation(event.values)
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDisplayRotation(): Int {
|
private fun getDisplayRotation(): Int {
|
||||||
return try {
|
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
windowManager.defaultDisplay.rotation
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Surface.ROTATION_0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remapForRotation(rotation: Int) {
|
private fun remapForRotation(rotation: Int) {
|
||||||
@@ -101,30 +96,23 @@ class SensorsRepo(
|
|||||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateOrientation(rotationVector: FloatArray) {
|
private fun handleSensorEvent(event: SensorEvent) {
|
||||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||||
remapForRotation(getDisplayRotation())
|
remapForRotation(getDisplayRotation())
|
||||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
|
||||||
|
|
||||||
if (!hasInitialReading) {
|
if (!hasInitialReading) {
|
||||||
smoothAzimuth = magneticAzimuth
|
smoothAzimuth = azimuth
|
||||||
smoothPitch = pitch
|
smoothPitch = pitch
|
||||||
hasInitialReading = true
|
hasInitialReading = true
|
||||||
} else {
|
} else {
|
||||||
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
|
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||||
smoothPitch = lowPass(smoothPitch, pitch)
|
smoothPitch = lowPass(smoothPitch, pitch)
|
||||||
}
|
}
|
||||||
|
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||||
_orientation.value = Pair(
|
|
||||||
round(smoothAzimuth * 10) / 10,
|
|
||||||
round(smoothPitch * 10) / 10
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard exponential low-pass filter. */
|
|
||||||
private fun lowPass(previous: Float, current: Float): Float {
|
private fun lowPass(previous: Float, current: Float): Float {
|
||||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||||
}
|
}
|
||||||
@@ -135,9 +123,10 @@ class SensorsRepo(
|
|||||||
*/
|
*/
|
||||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||||
var delta = current - previous
|
var delta = current - previous
|
||||||
// Normalise delta into the range (-180, 180]
|
|
||||||
while (delta > 180f) delta -= 360f
|
while (delta > 180f) delta -= 360f
|
||||||
while (delta <= -180f) delta += 360f
|
while (delta <= -180f) delta += 360f
|
||||||
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
|
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,7 @@ class SettingsRepo(
|
|||||||
private val keyUpdateTimestamp = "updateTimestamp"
|
private val keyUpdateTimestamp = "updateTimestamp"
|
||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||||
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
@@ -112,8 +113,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedTypes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, null)
|
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
if (typesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return typesString.split(separatorComma)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -333,6 +334,7 @@ class SettingsRepo(
|
|||||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
|
putString(keySstvMode, new.sstvMode)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -346,7 +348,8 @@ class SettingsRepo(
|
|||||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
look4SatDao.deleteRadios()
|
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -45,7 +46,10 @@ class RemoteSource(
|
|||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
httpClient.newCall(networkRequest).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
ByteArrayInputStream(response.body.bytes())
|
||||||
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.usecase
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import androidx.annotation.RequiresPermission
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
class AudioCapture : IAudioCapture {
|
||||||
|
|
||||||
|
override val sampleRate: Int = 44100
|
||||||
|
|
||||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
|
||||||
|
private val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||||
|
.coerceAtLeast(sampleRate) // at least 1 second buffer
|
||||||
|
|
||||||
|
@RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
|
||||||
|
override fun audioFlow(): Flow<FloatArray> = flow {
|
||||||
|
val recorder = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
sampleRate,
|
||||||
|
channelConfig,
|
||||||
|
audioFormat,
|
||||||
|
bufferSize * 4 // bytes for float
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
recorder.startRecording()
|
||||||
|
val chunkSize = sampleRate / 10 // ~100ms chunks
|
||||||
|
val buffer = FloatArray(chunkSize)
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
|
||||||
|
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
recorder.stop()
|
||||||
|
recorder.release()
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.usecase
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
//Saves images to the device gallery using MediaStore (API 29+) or direct file write (API < 29)
|
||||||
|
class SaveImage(private val context: Context) : ISaveImage {
|
||||||
|
|
||||||
|
override suspend fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
|
val filename = "SSTV_${modeName}_$timestamp.png"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
saveWithMediaStore(bitmap, filename)
|
||||||
|
} else {
|
||||||
|
saveToExternalStorage(bitmap, filename)
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveWithMediaStore(bitmap: Bitmap, filename: String) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Look4Sat")
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||||
|
?: throw IllegalStateException("Failed to create MediaStore entry")
|
||||||
|
resolver.openOutputStream(uri)?.use { stream ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
|
}
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToExternalStorage(bitmap: Bitmap, filename: String) {
|
||||||
|
val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||||
|
val dir = File(picturesDir, "Look4Sat")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val file = File(dir, filename)
|
||||||
|
FileOutputStream(file).use { stream ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,8 @@ data class OtherSettings(
|
|||||||
val stateOfLightTheme: Boolean,
|
val stateOfLightTheme: Boolean,
|
||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean
|
val shouldSeeWhatsNew: Boolean,
|
||||||
|
val sstvMode: String = "Auto"
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ data class OrbitalData(
|
|||||||
val argper: Double,
|
val argper: Double,
|
||||||
val meanan: Double,
|
val meanan: Double,
|
||||||
val catnum: Int,
|
val catnum: Int,
|
||||||
val bstar: Double
|
val bstar: Double,
|
||||||
|
val ndot: Double = 0.0
|
||||||
) {
|
) {
|
||||||
val xincl: Double = incl * DEG2RAD
|
val xincl: Double = incl * DEG2RAD
|
||||||
val xnodeo: Double = raan * DEG2RAD
|
val xnodeo: Double = raan * DEG2RAD
|
||||||
@@ -37,4 +38,29 @@ data class OrbitalData(
|
|||||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||||
|
|
||||||
|
/** Check if satellite has likely decayed by the given time. */
|
||||||
|
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||||
|
if (ndot == 0.0) return false
|
||||||
|
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||||
|
val epochDaynum = epochToDaynum(epoch)
|
||||||
|
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun epochToDaynum(epoch: Double): Double {
|
||||||
|
var year = kotlin.math.floor(epoch * 1E-3)
|
||||||
|
val day = (epoch * 1E-3 - year) * 1000.0
|
||||||
|
year = if (year < 57) year + 2000 else year + 1900
|
||||||
|
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||||
|
val jan1Jd = julianDateOfYear(year)
|
||||||
|
return jan1Jd + day - 2444238.5
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun julianDateOfYear(theYear: Double): Double {
|
||||||
|
val aYear = theYear - 1
|
||||||
|
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||||
|
val b = 2 - a + a / 4
|
||||||
|
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||||
|
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,9 +25,10 @@ data class OrbitalPass(
|
|||||||
val altitude: Int = 1000,
|
val altitude: Int = 1000,
|
||||||
val maxElevation: Double = 75.0,
|
val maxElevation: Double = 75.0,
|
||||||
val orbitalObject: OrbitalObject,
|
val orbitalObject: OrbitalObject,
|
||||||
val progress: Float = 0.0f
|
val progress: Float = 0.0f,
|
||||||
|
val hasDecayed: Boolean = false
|
||||||
) {
|
) {
|
||||||
val catNum: Int = orbitalObject.data.catnum
|
val catNum: Int = orbitalObject.data.catnum
|
||||||
val name: String = orbitalObject.data.name
|
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
||||||
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
||||||
}
|
}
|
||||||
+22
-1
@@ -1,6 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
@@ -10,6 +29,7 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val radioTrackingService: IRadioTrackingService
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
@@ -17,7 +37,8 @@ interface IMainContainer {
|
|||||||
fun provideSensorsRepo(): ISensorsRepo
|
fun provideSensorsRepo(): ISensorsRepo
|
||||||
fun provideTxRadioController(): IRadioController
|
fun provideTxRadioController(): IRadioController
|
||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
val radioTrackingService: IRadioTrackingService
|
fun provideAudioCapture(): IAudioCapture
|
||||||
|
fun provideSaveImage(): ISaveImage
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
+6
@@ -34,6 +34,12 @@ interface ISatelliteRepo {
|
|||||||
/** Whether the repo is currently calculating passes. */
|
/** Whether the repo is currently calculating passes. */
|
||||||
val isCalculating: StateFlow<Boolean>
|
val isCalculating: StateFlow<Boolean>
|
||||||
|
|
||||||
|
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
|
||||||
|
val selectedPass: StateFlow<Pair<Int, Long>>
|
||||||
|
|
||||||
|
/** Set the currently selected pass. */
|
||||||
|
fun selectPass(catNum: Int, aosTime: Long)
|
||||||
|
|
||||||
/** Load satellite objects from DB based on the current selection. */
|
/** Load satellite objects from DB based on the current selection. */
|
||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val orientation: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.sstv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.round
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
class SstvFrame(
|
||||||
|
val imagePixels: IntArray?,
|
||||||
|
val imageWidth: Int,
|
||||||
|
val imageHeight: Int,
|
||||||
|
val modeName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
class SstvDecoder(
|
||||||
|
sampleRate: Int = 44100,
|
||||||
|
scopeWidth: Int = 320,
|
||||||
|
scopeHeight: Int = 256,
|
||||||
|
private val channelSelect: Int = 0
|
||||||
|
) {
|
||||||
|
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||||
|
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
||||||
|
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
|
||||||
|
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||||
|
replay = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
val frames: SharedFlow<SstvFrame> = _frames
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lockMode(modeName: String) = decoder.setMode(modeName)
|
||||||
|
|
||||||
|
fun clearPixels() {
|
||||||
|
imageBuffer.line = -1
|
||||||
|
imageBuffer.pixels.fill(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitFrame() {
|
||||||
|
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 }
|
||||||
|
|
||||||
|
internal class SyncPulseDetector(sampleRate: Int) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SYNC_FREQ = 1200.0
|
||||||
|
const val BLACK_FREQ = 1500.0
|
||||||
|
const val WHITE_FREQ = 2300.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var detectedWidth: SyncPulseWidth = SyncPulseWidth.NineMs; private set
|
||||||
|
var pulseOffset: Int = 0; private set
|
||||||
|
var freqOffset: Float = 0f; private set
|
||||||
|
|
||||||
|
private val bandwidth = WHITE_FREQ - BLACK_FREQ
|
||||||
|
private val fm = FmDemodulator(bandwidth, sampleRate.toDouble())
|
||||||
|
private val min5ms: Int = round(0.0025 * sampleRate).toInt()
|
||||||
|
private val max5ms: Int = round(0.007 * sampleRate).toInt()
|
||||||
|
private val max9ms: Int = round(0.0145 * sampleRate).toInt()
|
||||||
|
private val max20ms: Int = round(0.025 * sampleRate).toInt()
|
||||||
|
private val filterDelay: Int
|
||||||
|
private val avgFilter: MovingAverage
|
||||||
|
private val delayLine: Delay
|
||||||
|
private val lowPass: ComplexFirFilter
|
||||||
|
private val oscillator: Phasor
|
||||||
|
private val syncFreqValue: Float
|
||||||
|
private val syncFreqTolerance: Float
|
||||||
|
private val trigger: SchmittTrigger
|
||||||
|
private var counter = 0
|
||||||
|
private var baseBand = Complex()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val filterLen = round(0.0025 * sampleRate).toInt() or 1
|
||||||
|
filterDelay = (filterLen - 1) / 2
|
||||||
|
avgFilter = MovingAverage(filterLen)
|
||||||
|
delayLine = Delay(filterLen)
|
||||||
|
val loFreq = 1000.0
|
||||||
|
val hiFreq = 2800.0
|
||||||
|
val cutoff = (hiFreq - loFreq) / 2
|
||||||
|
val lpLen = round(0.002 * sampleRate).toInt() or 1
|
||||||
|
lowPass = ComplexFirFilter(lpLen)
|
||||||
|
for (i in 0 until lpLen)
|
||||||
|
lowPass.taps[i] = (WindowFunctions.kaiser(2.0, i, lpLen) * WindowFunctions.sinc(
|
||||||
|
cutoff,
|
||||||
|
sampleRate.toDouble(),
|
||||||
|
i,
|
||||||
|
lpLen
|
||||||
|
)).toFloat()
|
||||||
|
val center = (loFreq + hiFreq) / 2
|
||||||
|
oscillator = Phasor(-center, sampleRate.toDouble())
|
||||||
|
syncFreqValue = ((SYNC_FREQ - center) * 2 / bandwidth).toFloat()
|
||||||
|
syncFreqTolerance = (50 * 2 / bandwidth).toFloat()
|
||||||
|
val porchFreq = 1500.0
|
||||||
|
val hiThresh = (SYNC_FREQ + porchFreq) / 2
|
||||||
|
val loThresh = (SYNC_FREQ + hiThresh) / 2
|
||||||
|
trigger = SchmittTrigger(
|
||||||
|
((loThresh - center) * 2 / bandwidth).toFloat(),
|
||||||
|
((hiThresh - center) * 2 / bandwidth).toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
2 -> baseBand.set(buffer[2 * i + 1])
|
||||||
|
3 -> baseBand.set(buffer[2 * i] + buffer[2 * i + 1])
|
||||||
|
4 -> baseBand.set(buffer[2 * i], buffer[2 * i + 1])
|
||||||
|
else -> baseBand.set(buffer[i])
|
||||||
|
}
|
||||||
|
baseBand = lowPass.filter(baseBand.mul(oscillator.rotate()))
|
||||||
|
val freq = fm.demodulate(baseBand)
|
||||||
|
val avg = avgFilter.avg(freq)
|
||||||
|
val delayed = delayLine.push(avg)
|
||||||
|
buffer[i] = freq
|
||||||
|
if (!trigger.process(avg)) {
|
||||||
|
++counter
|
||||||
|
} else if (counter !in min5ms..max20ms || abs(delayed - syncFreqValue) > syncFreqTolerance) {
|
||||||
|
counter = 0
|
||||||
|
} else {
|
||||||
|
detectedWidth = when {
|
||||||
|
counter < max5ms -> SyncPulseWidth.FiveMs
|
||||||
|
counter < max9ms -> SyncPulseWidth.NineMs
|
||||||
|
else -> SyncPulseWidth.TwentyMs
|
||||||
|
}
|
||||||
|
pulseOffset = i - filterDelay
|
||||||
|
freqOffset = delayed - syncFreqValue
|
||||||
|
detected = true
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DecoderEngine(
|
||||||
|
private val scopeBuffer: PixelBuffer,
|
||||||
|
private val imageBuffer: PixelBuffer,
|
||||||
|
rawName: String,
|
||||||
|
sampleRate: Int
|
||||||
|
) {
|
||||||
|
private val pixelBuffer = PixelBuffer(800, 2)
|
||||||
|
private val detector = SyncPulseDetector(sampleRate)
|
||||||
|
private val pulseFilter: MovingAverage
|
||||||
|
private val pulseFilterDelay: Int
|
||||||
|
private val scanLineBuffer: FloatArray
|
||||||
|
private val scratch: FloatArray
|
||||||
|
private val sync5ms = IntArray(5)
|
||||||
|
private val sync9ms = IntArray(5)
|
||||||
|
private val sync20ms = IntArray(5)
|
||||||
|
private val lines5ms = IntArray(4)
|
||||||
|
private val lines9ms = IntArray(4)
|
||||||
|
private val lines20ms = IntArray(4)
|
||||||
|
private val offsets5ms = FloatArray(5)
|
||||||
|
private val offsets9ms = FloatArray(5)
|
||||||
|
private val offsets20ms = FloatArray(5)
|
||||||
|
private val visFreqs = FloatArray(10)
|
||||||
|
private val scanLineMin: Int
|
||||||
|
private val syncTolerance: Int
|
||||||
|
private val lineTolerance: Int
|
||||||
|
private val leaderLen: Int
|
||||||
|
private val leaderTol: Int
|
||||||
|
private val transition: Int
|
||||||
|
private val visBitLen: Int
|
||||||
|
private val visLen: Int
|
||||||
|
private val rawMode: SstvMode
|
||||||
|
private val modes5ms: ArrayList<SstvMode>
|
||||||
|
private val modes9ms: ArrayList<SstvMode>
|
||||||
|
private val modes20ms: ArrayList<SstvMode>
|
||||||
|
|
||||||
|
var currentMode: SstvMode; private set
|
||||||
|
val allModes: List<SstvMode> get() = modes5ms + modes9ms + modes20ms
|
||||||
|
|
||||||
|
private var lockMode = false
|
||||||
|
private var sample = 0
|
||||||
|
private var leaderBreak = 0
|
||||||
|
private var lastSync = 0
|
||||||
|
private var curLineSamples: Int
|
||||||
|
private var lastOffset = 0f
|
||||||
|
|
||||||
|
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)
|
||||||
|
scanLineBuffer = FloatArray(round(7.0 * sampleRate).toInt())
|
||||||
|
scratch = FloatArray(round(1.1 * sampleRate).toInt())
|
||||||
|
leaderLen = round(0.3 * sampleRate).toInt()
|
||||||
|
leaderTol = round(0.06 * sampleRate).toInt()
|
||||||
|
transition = round(0.0005 * sampleRate).toInt()
|
||||||
|
visBitLen = round(0.03 * sampleRate).toInt()
|
||||||
|
visLen = round(0.3 * sampleRate).toInt()
|
||||||
|
scanLineMin = round(0.05 * sampleRate).toInt()
|
||||||
|
syncTolerance = round(0.03 * sampleRate).toInt()
|
||||||
|
lineTolerance = round(0.001 * sampleRate).toInt()
|
||||||
|
rawMode = RawMode(rawName, sampleRate)
|
||||||
|
val robot36 = Robot36Mode(sampleRate)
|
||||||
|
currentMode = robot36
|
||||||
|
curLineSamples = robot36.scanLineSamples
|
||||||
|
modes5ms = arrayListOf(
|
||||||
|
RgbMode.wraaseSc2180(sampleRate),
|
||||||
|
RgbMode.martin("1", 44, 0.146432, sampleRate),
|
||||||
|
RgbMode.martin("2", 40, 0.073216, sampleRate)
|
||||||
|
)
|
||||||
|
modes9ms = arrayListOf(
|
||||||
|
robot36, Robot72Mode(sampleRate),
|
||||||
|
RgbMode.scottie("1", 60, 0.138240, sampleRate),
|
||||||
|
RgbMode.scottie("2", 56, 0.088064, sampleRate),
|
||||||
|
RgbMode.scottie("DX", 76, 0.3456, sampleRate)
|
||||||
|
)
|
||||||
|
modes20ms = arrayListOf(
|
||||||
|
PdMode("50", 93, 320, 256, 0.09152, sampleRate),
|
||||||
|
PdMode("90", 99, 320, 256, 0.17024, sampleRate),
|
||||||
|
PdMode("120", 95, 640, 496, 0.1216, sampleRate),
|
||||||
|
PdMode("160", 98, 512, 400, 0.195584, sampleRate),
|
||||||
|
PdMode("180", 96, 640, 496, 0.18304, sampleRate),
|
||||||
|
PdMode("240", 97, 640, 496, 0.24448, sampleRate),
|
||||||
|
PdMode("290", 94, 800, 616, 0.2288, sampleRate)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
|
var newLines = false
|
||||||
|
val detected = detector.process(recordBuffer, channelSelect)
|
||||||
|
var syncIdx = sample + detector.pulseOffset
|
||||||
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
|
for (j in 0 until recordBuffer.size / channels) {
|
||||||
|
if (sample >= scanLineBuffer.size) {
|
||||||
|
shift(curLineSamples)
|
||||||
|
syncIdx -= curLineSamples
|
||||||
|
if (sample >= scanLineBuffer.size) sample = scanLineBuffer.size - 1
|
||||||
|
}
|
||||||
|
scanLineBuffer[sample++] = recordBuffer[j]
|
||||||
|
}
|
||||||
|
if (detected) {
|
||||||
|
when (detector.detectedWidth) {
|
||||||
|
SyncPulseWidth.FiveMs -> newLines = processPulse(modes5ms, offsets5ms, sync5ms, lines5ms, syncIdx)
|
||||||
|
SyncPulseWidth.NineMs -> {
|
||||||
|
leaderBreak = syncIdx; newLines = processPulse(modes9ms, offsets9ms, sync9ms, lines9ms, syncIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPulseWidth.TwentyMs -> {
|
||||||
|
leaderBreak = syncIdx; newLines = processPulse(modes20ms, offsets20ms, sync20ms, lines20ms, syncIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (handleHeader()) {
|
||||||
|
newLines = true
|
||||||
|
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||||
|
copyLines(
|
||||||
|
currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
lastSync,
|
||||||
|
curLineSamples,
|
||||||
|
lastOffset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lastSync += curLineSamples; newLines = true
|
||||||
|
}
|
||||||
|
return newLines
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMode(name: String) {
|
||||||
|
val mode = allModes.firstOrNull { it.name == name }
|
||||||
|
if (mode == currentMode) {
|
||||||
|
lockMode = true; return
|
||||||
|
}
|
||||||
|
if (mode != null) {
|
||||||
|
lockMode = true; imageBuffer.line = -1; currentMode = mode; curLineSamples = mode.scanLineSamples; return
|
||||||
|
}
|
||||||
|
lockMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
||||||
|
private fun stdDev(a: IntArray, m: Double): Double {
|
||||||
|
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun meanF(a: FloatArray): Float {
|
||||||
|
var s = 0f; for (v in a) s += v; return s / a.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectMode(modes: ArrayList<SstvMode>, samples: Int): SstvMode {
|
||||||
|
var best: SstvMode = rawMode
|
||||||
|
var bestD = Int.MAX_VALUE
|
||||||
|
for (m in modes) {
|
||||||
|
val d = abs(samples - m.scanLineSamples); if (d <= lineTolerance && d < bestD) {
|
||||||
|
bestD = d; best = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
val line = scopeBuffer.width * scopeBuffer.line
|
||||||
|
pixelBuffer.pixels.copyInto(scopeBuffer.pixels, line, row * pixelBuffer.width, row * pixelBuffer.width + w)
|
||||||
|
scopeBuffer.pixels.fill(0, line + w, line + scopeBuffer.width)
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels,
|
||||||
|
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||||
|
line,
|
||||||
|
line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyScaled(scale: Int) {
|
||||||
|
for (row in 0 until pixelBuffer.height) {
|
||||||
|
val line = scopeBuffer.width * scopeBuffer.line
|
||||||
|
for (col in 0 until pixelBuffer.width) for (i in 0 until scale) scopeBuffer.pixels[line + col * scale + i] =
|
||||||
|
pixelBuffer.pixels[pixelBuffer.width * row + col]
|
||||||
|
scopeBuffer.pixels.fill(0, line + pixelBuffer.width * scale, line + scopeBuffer.width)
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels,
|
||||||
|
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||||
|
line,
|
||||||
|
line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
repeat(scale - 1) {
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels, scopeBuffer.width * scopeBuffer.line, line, line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels,
|
||||||
|
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||||
|
line,
|
||||||
|
line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyLines(ok: Boolean) {
|
||||||
|
if (!ok) return
|
||||||
|
var finish = false
|
||||||
|
if (imageBuffer.line in 0 until imageBuffer.height && imageBuffer.width == pixelBuffer.width) {
|
||||||
|
val w = imageBuffer.width
|
||||||
|
for (row in 0 until pixelBuffer.height) {
|
||||||
|
if (imageBuffer.line >= imageBuffer.height) break
|
||||||
|
pixelBuffer.pixels.copyInto(imageBuffer.pixels, imageBuffer.line * w, row * w, row * w + w)
|
||||||
|
imageBuffer.line++
|
||||||
|
}
|
||||||
|
finish = imageBuffer.line == imageBuffer.height
|
||||||
|
}
|
||||||
|
val scale = scopeBuffer.width / pixelBuffer.width
|
||||||
|
if (scale <= 1) copyUnscaled() else copyScaled(scale)
|
||||||
|
if (finish) drawLines(0xff000000.toInt(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawLines(color: Int, count: Int) {
|
||||||
|
repeat(count) {
|
||||||
|
scopeBuffer.pixels.fill(
|
||||||
|
color,
|
||||||
|
scopeBuffer.line * scopeBuffer.width,
|
||||||
|
(scopeBuffer.line + 1) * scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.pixels.fill(
|
||||||
|
color,
|
||||||
|
(scopeBuffer.line + scopeBuffer.height / 2) * scopeBuffer.width,
|
||||||
|
(scopeBuffer.line + 1 + scopeBuffer.height / 2) * scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjust(pulses: IntArray, shift: Int) {
|
||||||
|
for (i in pulses.indices) pulses[i] -= shift
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shift(amount: Int) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleHeader(): Boolean {
|
||||||
|
if (leaderBreak < visBitLen + leaderTol || sample < leaderBreak + leaderLen + leaderTol + visLen + visBitLen) return false
|
||||||
|
val bp = leaderBreak; leaderBreak = 0
|
||||||
|
var preFreq = 0f
|
||||||
|
for (i in 0 until leaderTol) preFreq += scanLineBuffer[bp - visBitLen - leaderTol + i]
|
||||||
|
val toneFreq = 1900f
|
||||||
|
val center = 1900f
|
||||||
|
val tol = 50f
|
||||||
|
val halfBw = 400f
|
||||||
|
preFreq = preFreq * halfBw / leaderTol + center
|
||||||
|
if (abs(preFreq - toneFreq) > tol) return false
|
||||||
|
var ldrFreq = 0f
|
||||||
|
for (i in transition until leaderLen - leaderTol) ldrFreq += scanLineBuffer[bp + i]
|
||||||
|
val ldrOffset = ldrFreq / (leaderLen - transition - leaderTol)
|
||||||
|
ldrFreq = ldrOffset * halfBw + center
|
||||||
|
if (abs(ldrFreq - toneFreq) > tol) return false
|
||||||
|
val stopFreq = 1200f
|
||||||
|
val pulseThr = ((stopFreq + toneFreq) / 2 - center) / halfBw
|
||||||
|
var vBegin = bp + leaderLen - leaderTol
|
||||||
|
val vEnd = bp + leaderLen + leaderTol + visBitLen
|
||||||
|
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[vBegin++] - ldrOffset) }
|
||||||
|
while (++vBegin < vEnd) if (pulseFilter.avg(scanLineBuffer[vBegin] - ldrOffset) < pulseThr) break
|
||||||
|
if (vBegin >= vEnd) return false
|
||||||
|
vBegin -= pulseFilterDelay
|
||||||
|
val visEnd = vBegin + visLen
|
||||||
|
visFreqs.fill(0f)
|
||||||
|
for (j in 0 until 10) for (i in transition until visBitLen - transition) visFreqs[j] += scanLineBuffer[vBegin + visBitLen * j + i] - ldrOffset
|
||||||
|
for (i in 0 until 10) visFreqs[i] = visFreqs[i] * halfBw / (visBitLen - 2 * transition) + center
|
||||||
|
if (abs(visFreqs[0] - stopFreq) > tol || abs(visFreqs[9] - stopFreq) > tol) return false
|
||||||
|
for (i in 1 until 9) if (abs(visFreqs[i] - 1100f) > tol && abs(visFreqs[i] - 1300f) > tol) return false
|
||||||
|
var vis = 0
|
||||||
|
for (i in 0 until 8) vis = vis or ((if (visFreqs[i + 1] < stopFreq) 1 else 0) shl i)
|
||||||
|
var chk = true; for (i in 0 until 8) chk = chk xor ((vis and (1 shl i)) != 0)
|
||||||
|
vis = vis and 127; if (!chk) return false
|
||||||
|
val syncThr = ((1200f + 1500f) / 2 - center) / halfBw
|
||||||
|
var sIdx = visEnd - visBitLen
|
||||||
|
val sMax = visEnd + visBitLen
|
||||||
|
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[sIdx++] - ldrOffset) }
|
||||||
|
while (++sIdx < sMax) if (pulseFilter.avg(scanLineBuffer[sIdx] - ldrOffset) > syncThr) break
|
||||||
|
if (sIdx >= sMax) return false
|
||||||
|
sIdx -= pulseFilterDelay
|
||||||
|
val mode: SstvMode
|
||||||
|
val pulses: IntArray
|
||||||
|
val lines: IntArray
|
||||||
|
val f5 = modes5ms.firstOrNull { it.visCode == vis }
|
||||||
|
val f9 = modes9ms.firstOrNull { it.visCode == vis }
|
||||||
|
val f20 = modes20ms.firstOrNull { it.visCode == vis }
|
||||||
|
when {
|
||||||
|
f5 != null -> {
|
||||||
|
mode = f5; pulses = sync5ms; lines = lines5ms
|
||||||
|
}
|
||||||
|
|
||||||
|
f9 != null -> {
|
||||||
|
mode = f9; pulses = sync9ms; lines = lines9ms
|
||||||
|
}
|
||||||
|
|
||||||
|
f20 != null -> {
|
||||||
|
mode = f20; pulses = sync20ms; lines = lines20ms
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (!lockMode) drawLines(0xffff0000.toInt(), 8); return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lockMode && mode != currentMode) return false
|
||||||
|
mode.resetState()
|
||||||
|
imageBuffer.width = mode.width; imageBuffer.height = mode.height
|
||||||
|
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
|
||||||
|
if (mode.firstSyncPulseIndex > 0) oldest -= curLineSamples
|
||||||
|
for (i in pulses.indices) pulses[i] = oldest + i * curLineSamples
|
||||||
|
lines.fill(curLineSamples)
|
||||||
|
shift(lastSync + mode.firstPixelSampleIndex)
|
||||||
|
drawLines(0xff00ff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPulse(
|
||||||
|
modes: ArrayList<SstvMode>,
|
||||||
|
freqOffs: FloatArray,
|
||||||
|
syncPulses: IntArray,
|
||||||
|
lineLen: IntArray,
|
||||||
|
latest: Int
|
||||||
|
): Boolean {
|
||||||
|
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
||||||
|
syncPulses[syncPulses.size - 1] = latest
|
||||||
|
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
||||||
|
lineLen[lineLen.size - 1] = syncPulses.last() - syncPulses[syncPulses.size - 2]
|
||||||
|
for (i in 1 until freqOffs.size) freqOffs[i - 1] = freqOffs[i]
|
||||||
|
freqOffs[freqOffs.size - 1] = detector.freqOffset
|
||||||
|
if (lineLen[0] == 0) return false
|
||||||
|
val m = mean(lineLen)
|
||||||
|
val lineSamples = round(m).toInt()
|
||||||
|
if (lineSamples < scanLineMin || lineSamples > scratch.size) return false
|
||||||
|
if (stdDev(lineLen, m) > lineTolerance) return false
|
||||||
|
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 =
|
||||||
|
currentMode != prev || abs(curLineSamples - lineSamples) > lineTolerance || abs(lastSync + lineSamples - syncPulses.last()) > syncTolerance
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
drawLines(0xff000000.toInt(), 10); drawLines(0xff00ffff.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||||
|
}
|
||||||
|
val offset = meanF(freqOffs)
|
||||||
|
if (syncPulses[0] >= lineSamples && changed) {
|
||||||
|
val end = syncPulses[0]
|
||||||
|
val extra = end / lineSamples
|
||||||
|
val first = end - extra * lineSamples
|
||||||
|
var p = first; while (p < end) {
|
||||||
|
copyLines(
|
||||||
|
currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
p,
|
||||||
|
lineSamples,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
); p += lineSamples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val start = if (changed) 0 else lineLen.size - 1
|
||||||
|
for (i in start until lineLen.size) copyLines(
|
||||||
|
currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
syncPulses[i],
|
||||||
|
lineLen[i],
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lastSync = syncPulses.last(); curLineSamples = lineSamples; lastOffset = offset
|
||||||
|
shift(lastSync + currentMode.firstPixelSampleIndex)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.sstv
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.round
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
|
||||||
|
|
||||||
|
fun set(real: Float, imag: Float): Complex {
|
||||||
|
this.real = real; this.imag = imag; return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(real: Float): Complex = set(real, 0f)
|
||||||
|
|
||||||
|
fun abs(): Float = sqrt(real * real + imag * imag)
|
||||||
|
|
||||||
|
fun mul(other: Complex): Complex {
|
||||||
|
val tmp = real * other.real - imag * other.imag
|
||||||
|
imag = real * other.imag + imag * other.real
|
||||||
|
real = tmp
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun div(value: Float): Complex {
|
||||||
|
real /= value; imag /= value; return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object WindowFunctions {
|
||||||
|
|
||||||
|
fun sinc(cutoff: Double, rate: Double, n: Int, nN: Int): Double {
|
||||||
|
val f = 2 * cutoff / rate
|
||||||
|
val x = n - (nN - 1) / 2.0
|
||||||
|
val fx = f * x
|
||||||
|
return if (fx == 0.0) f else f * sin(PI * fx) / (PI * fx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun kaiser(a: Double, n: Int, nN: Int): Double {
|
||||||
|
fun square(v: Double) = v * v
|
||||||
|
fun i0(x: Double): Double {
|
||||||
|
val terms = DoubleArray(35)
|
||||||
|
terms[0] = 1.0
|
||||||
|
var v = 1.0
|
||||||
|
for (m in 1 until 35) {
|
||||||
|
v *= x / (2 * m); terms[m] = square(v)
|
||||||
|
}
|
||||||
|
terms.sort()
|
||||||
|
var sum = 0.0; for (m in 34 downTo 0) sum += terms[m]
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
return i0(PI * a * sqrt(1 - square((2.0 * n) / (nN - 1) - 1))) / i0(PI * a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 buf = FloatArray(length)
|
||||||
|
private var pos = 0
|
||||||
|
private var runningSum = 0f
|
||||||
|
|
||||||
|
fun add(input: Float) {
|
||||||
|
runningSum += input - buf[pos]
|
||||||
|
buf[pos] = input
|
||||||
|
if (++pos >= length) pos = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sum(): Float = runningSum
|
||||||
|
fun sum(input: Float): Float {
|
||||||
|
add(input); return sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class MovingAverage(length: Int) : MovingSum(length) {
|
||||||
|
fun avg(input: Float): Float = sum(input) / length
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Ema {
|
||||||
|
private var alpha: Float = 1f
|
||||||
|
private var prev: Float = 0f
|
||||||
|
|
||||||
|
fun process(input: Float): Float {
|
||||||
|
prev = prev * (1 - alpha) + alpha * input; return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCutoff(freq: Double, rate: Double, order: Int = 1) {
|
||||||
|
alpha = computeAlpha(freq, rate, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
prev = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun computeAlpha(freq: Double, rate: Double, order: Int = 1): Float {
|
||||||
|
val x = cos(2 * PI * (freq.coerceAtMost(rate * 0.499)) / rate)
|
||||||
|
val discriminant = (x * (x - 4) + 3).coerceAtLeast(0.0)
|
||||||
|
return (x - 1 + sqrt(discriminant)).coerceIn(0.0, 1.0).pow(1.0 / order).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withCutoff(freq: Double, rate: Double, order: Int = 1): Ema {
|
||||||
|
return Ema().also { it.setCutoff(freq, rate, order) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Phasor(freq: Double, rate: Double) {
|
||||||
|
private val value = Complex(1f, 0f)
|
||||||
|
private val delta: Complex = run {
|
||||||
|
val omega = 2 * PI * freq / rate
|
||||||
|
Complex(cos(omega).toFloat(), sin(omega).toFloat())
|
||||||
|
}
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
private val scale = (sampleRate / (bandwidth * PI)).toFloat()
|
||||||
|
private val pi = PI.toFloat()
|
||||||
|
private val twoPi = (2 * PI).toFloat()
|
||||||
|
private var prev = 0f
|
||||||
|
|
||||||
|
fun demodulate(input: Complex): Float {
|
||||||
|
// 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) {
|
||||||
|
private val real = FloatArray(length)
|
||||||
|
private val imag = FloatArray(length)
|
||||||
|
private val sum = Complex()
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
val taps = FloatArray(length)
|
||||||
|
|
||||||
|
fun filter(input: Complex): Complex {
|
||||||
|
real[pos] = input.real; imag[pos] = input.imag
|
||||||
|
if (++pos >= length) pos = 0
|
||||||
|
sum.real = 0f; sum.imag = 0f
|
||||||
|
for (tap in taps) {
|
||||||
|
sum.real += tap * real[pos]; sum.imag += tap * imag[pos]; if (++pos >= length) pos = 0
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Delay(val length: Int) {
|
||||||
|
private val buf = FloatArray(length)
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
fun push(input: Float): Float {
|
||||||
|
val tmp = buf[pos]; buf[pos] = input; if (++pos >= length) pos = 0; return tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class SchmittTrigger(private val low: Float, private val high: Float) {
|
||||||
|
private var state = false
|
||||||
|
|
||||||
|
fun process(input: Float): Boolean {
|
||||||
|
if (state) {
|
||||||
|
if (input < low) state = false
|
||||||
|
} else {
|
||||||
|
if (input > high) state = true
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ColorConverter {
|
||||||
|
|
||||||
|
private fun clamp(v: Int) = v.coerceIn(0, 255)
|
||||||
|
private fun toInt(level: Float) = clamp(round(255 * level).toInt())
|
||||||
|
private fun compress(level: Float) = toInt(sqrt(level.coerceIn(0f, 1f)))
|
||||||
|
|
||||||
|
private fun yuv2rgb(yY: Int, uU: Int, vV: Int): Int {
|
||||||
|
val y = yY - 16
|
||||||
|
val u = uU - 128
|
||||||
|
val v = vV - 128
|
||||||
|
val r = clamp((298 * y + 409 * v + 128) shr 8)
|
||||||
|
val g = clamp((298 * y - 100 * u - 208 * v + 128) shr 8)
|
||||||
|
val b = clamp((298 * y + 516 * u + 128) shr 8)
|
||||||
|
return 0xff000000.toInt() or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gray(level: Float): Int = 0xff000000.toInt() or (0x00010101 * compress(level))
|
||||||
|
fun rgb(r: Float, g: Float, b: Float): Int = 0xff000000.toInt() or (toInt(r) shl 16) or (toInt(g) shl 8) or toInt(b)
|
||||||
|
fun yuv2rgb(yY: Float, uU: Float, vV: Float): Int = yuv2rgb(toInt(yY), toInt(uU), toInt(vV))
|
||||||
|
fun yuv2rgb(packed: Int): Int = yuv2rgb((packed shr 16) and 0xff, (packed shr 8) and 0xff, packed and 0xff)
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.sstv
|
||||||
|
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
internal class PixelBuffer(var width: Int, var height: Int) {
|
||||||
|
var pixels = IntArray(width * height)
|
||||||
|
var line = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed interface SstvMode {
|
||||||
|
val name: String
|
||||||
|
val visCode: Int
|
||||||
|
val width: Int
|
||||||
|
val height: Int
|
||||||
|
val firstPixelSampleIndex: Int
|
||||||
|
val firstSyncPulseIndex: Int
|
||||||
|
val scanLineSamples: Int
|
||||||
|
|
||||||
|
fun resetState() {}
|
||||||
|
|
||||||
|
fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class RgbMode(
|
||||||
|
override val name: String,
|
||||||
|
override val visCode: Int,
|
||||||
|
private val hPixels: Int,
|
||||||
|
private val vPixels: Int,
|
||||||
|
override val firstSyncPulseIndex: Int,
|
||||||
|
override val scanLineSamples: Int,
|
||||||
|
override val firstPixelSampleIndex: Int,
|
||||||
|
private val redBegin: Int,
|
||||||
|
private val redLen: Int,
|
||||||
|
private val greenBegin: Int,
|
||||||
|
private val greenLen: Int,
|
||||||
|
private val blueBegin: Int,
|
||||||
|
private val blueLen: Int,
|
||||||
|
private val endSamples: Int,
|
||||||
|
) : SstvMode {
|
||||||
|
override val width get() = hPixels
|
||||||
|
override val height get() = vPixels
|
||||||
|
private val ema = Ema.withCutoff(hPixels.toDouble(), (2 * greenLen).toDouble(), 2)
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
val begin = firstPixelSampleIndex
|
||||||
|
if (syncPulseIndex + begin < 0 || syncPulseIndex + endSamples > scanLine.size) return false
|
||||||
|
ema.reset()
|
||||||
|
for (i in 0 until endSamples - begin) scratch[i] = ema.process(scanLine[syncPulseIndex + begin + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in endSamples - begin - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until hPixels) {
|
||||||
|
val r = redBegin + (i * redLen) / hPixels
|
||||||
|
val g = greenBegin + (i * greenLen) / hPixels
|
||||||
|
val b = blueBegin + (i * blueLen) / hPixels
|
||||||
|
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[r], scratch[g], scratch[b])
|
||||||
|
}
|
||||||
|
pixelBuffer.width = hPixels; pixelBuffer.height = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun martin(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
|
||||||
|
val sync = 0.004862
|
||||||
|
val sep = 0.000572
|
||||||
|
val scanLine = sync + sep + 3 * (channelSec + sep)
|
||||||
|
val gEnd = sep + channelSec
|
||||||
|
val bBegin = gEnd + sep
|
||||||
|
val bEnd = bBegin + channelSec
|
||||||
|
val rBegin = bEnd + sep
|
||||||
|
val rEnd = rBegin + channelSec
|
||||||
|
return fromSeconds(
|
||||||
|
name = "Martin $variant",
|
||||||
|
code = code,
|
||||||
|
firstSyncSec = 0.0,
|
||||||
|
scanLineSec = scanLine,
|
||||||
|
beginSec = sep,
|
||||||
|
rBeginSec = rBegin,
|
||||||
|
rEndSec = rEnd,
|
||||||
|
gBeginSec = sep,
|
||||||
|
gEndSec = gEnd,
|
||||||
|
bBeginSec = bBegin,
|
||||||
|
bEndSec = bEnd,
|
||||||
|
endSec = rEnd,
|
||||||
|
sr = sampleRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scottie(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
|
||||||
|
val sync = 0.009
|
||||||
|
val sep = 0.0015
|
||||||
|
val firstSync = sync + 2 * (sep + channelSec)
|
||||||
|
val scanLine = sync + 3 * (channelSec + sep)
|
||||||
|
val bEnd = -sync
|
||||||
|
val bBegin = bEnd - channelSec
|
||||||
|
val gEnd = bBegin - sep
|
||||||
|
val gBegin = gEnd - channelSec
|
||||||
|
val rEnd = sep + channelSec
|
||||||
|
return fromSeconds(
|
||||||
|
name = "Scottie $variant",
|
||||||
|
code = code,
|
||||||
|
firstSyncSec = firstSync,
|
||||||
|
scanLineSec = scanLine,
|
||||||
|
beginSec = gBegin,
|
||||||
|
rBeginSec = sep,
|
||||||
|
rEndSec = rEnd,
|
||||||
|
gBeginSec = gBegin,
|
||||||
|
gEndSec = gEnd,
|
||||||
|
bBeginSec = bBegin,
|
||||||
|
bEndSec = bEnd,
|
||||||
|
endSec = rEnd,
|
||||||
|
sr = sampleRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wraaseSc2180(sampleRate: Int): RgbMode {
|
||||||
|
val sync = 0.0055225
|
||||||
|
val porch = 0.0005
|
||||||
|
val ch = 0.235
|
||||||
|
val scanLine = sync + porch + 3 * ch
|
||||||
|
val rEnd = porch + ch
|
||||||
|
val gEnd = rEnd + ch
|
||||||
|
val bEnd = gEnd + ch
|
||||||
|
return fromSeconds(
|
||||||
|
name = "Wraase SC2-180",
|
||||||
|
code = 55,
|
||||||
|
firstSyncSec = 0.0,
|
||||||
|
scanLineSec = scanLine,
|
||||||
|
beginSec = porch,
|
||||||
|
rBeginSec = porch,
|
||||||
|
rEndSec = rEnd,
|
||||||
|
gBeginSec = rEnd,
|
||||||
|
gEndSec = gEnd,
|
||||||
|
bBeginSec = gEnd,
|
||||||
|
bEndSec = bEnd,
|
||||||
|
endSec = bEnd,
|
||||||
|
sr = sampleRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fromSeconds(
|
||||||
|
name: String, code: Int, w: Int = 320, h: Int = 256,
|
||||||
|
firstSyncSec: Double, scanLineSec: Double, beginSec: Double,
|
||||||
|
rBeginSec: Double, rEndSec: Double, gBeginSec: Double, gEndSec: Double,
|
||||||
|
bBeginSec: Double, bEndSec: Double, endSec: Double, sr: Int
|
||||||
|
): RgbMode {
|
||||||
|
val begin = round(beginSec * sr).toInt()
|
||||||
|
return RgbMode(
|
||||||
|
name = name, visCode = code, hPixels = w, vPixels = h,
|
||||||
|
firstSyncPulseIndex = round(firstSyncSec * sr).toInt(),
|
||||||
|
scanLineSamples = round(scanLineSec * sr).toInt(),
|
||||||
|
firstPixelSampleIndex = begin,
|
||||||
|
redBegin = round(rBeginSec * sr).toInt() - begin,
|
||||||
|
redLen = round((rEndSec - rBeginSec) * sr).toInt(),
|
||||||
|
greenBegin = round(gBeginSec * sr).toInt() - begin,
|
||||||
|
greenLen = round((gEndSec - gBeginSec) * sr).toInt(),
|
||||||
|
blueBegin = round(bBeginSec * sr).toInt() - begin,
|
||||||
|
blueLen = round((bEndSec - bBeginSec) * sr).toInt(),
|
||||||
|
endSamples = round(endSec * sr).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Robot36Mode(sampleRate: Int) : SstvMode {
|
||||||
|
override val name = "Robot 36 Color"
|
||||||
|
override val visCode = 8
|
||||||
|
override val width = 320
|
||||||
|
override val height = 240
|
||||||
|
override val firstSyncPulseIndex = 0
|
||||||
|
override val scanLineSamples: Int
|
||||||
|
override val firstPixelSampleIndex: Int
|
||||||
|
|
||||||
|
private val lumSamples: Int
|
||||||
|
private val sepSamples: Int
|
||||||
|
private val chromSamples: Int
|
||||||
|
private val lumBegin: Int
|
||||||
|
private val sepBegin: Int
|
||||||
|
private val chromBegin: Int
|
||||||
|
private val end: Int
|
||||||
|
private val ema: Ema
|
||||||
|
private var lastEven = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
val syncPorch = 0.003
|
||||||
|
val lum = 0.088
|
||||||
|
val sep = 0.0045
|
||||||
|
val porch = 0.0015
|
||||||
|
val chrom = 0.044
|
||||||
|
scanLineSamples = round((0.009 + syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
|
||||||
|
lumSamples = round(lum * sampleRate).toInt(); sepSamples = round(sep * sampleRate).toInt()
|
||||||
|
chromSamples = round(chrom * sampleRate).toInt()
|
||||||
|
lumBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = lumBegin
|
||||||
|
sepBegin = round((syncPorch + lum) * sampleRate).toInt()
|
||||||
|
chromBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
|
||||||
|
end = round((syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
|
||||||
|
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resetState() {
|
||||||
|
lastEven = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||||
|
var sep = 0f
|
||||||
|
for (i in 0 until sepSamples) sep += scanLine[syncPulseIndex + sepBegin + i]
|
||||||
|
sep = sep / sepSamples - freqOffset
|
||||||
|
var even = sep < 0
|
||||||
|
if (sep < -1.1f || (sep > -0.9f && sep < 0.9f) || sep > 1.1f) even = !lastEven
|
||||||
|
lastEven = even
|
||||||
|
ema.reset()
|
||||||
|
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until width) {
|
||||||
|
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] =
|
||||||
|
ColorConverter.yuv2rgb((oddYuv and 0x00ffff00) or (evenYuv and 0x000000ff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pixelBuffer.width = width; pixelBuffer.height = 2
|
||||||
|
return !even
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Robot72Mode(sampleRate: Int) : SstvMode {
|
||||||
|
override val name = "Robot 72 Color"
|
||||||
|
override val visCode = 12
|
||||||
|
override val width = 320
|
||||||
|
override val height = 240
|
||||||
|
override val firstSyncPulseIndex = 0
|
||||||
|
override val scanLineSamples: Int
|
||||||
|
override val firstPixelSampleIndex: Int
|
||||||
|
|
||||||
|
private val lumSamples: Int
|
||||||
|
private val chromSamples: Int
|
||||||
|
private val yBegin: Int
|
||||||
|
private val vBegin: Int
|
||||||
|
private val uBegin: Int
|
||||||
|
private val end: Int
|
||||||
|
private val ema: Ema
|
||||||
|
|
||||||
|
init {
|
||||||
|
val syncPorch = 0.003
|
||||||
|
val lum = 0.138
|
||||||
|
val sep = 0.0045
|
||||||
|
val porch = 0.0015
|
||||||
|
val chrom = 0.069
|
||||||
|
scanLineSamples = round((0.009 + syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
|
||||||
|
lumSamples = round(lum * sampleRate).toInt(); chromSamples = round(chrom * sampleRate).toInt()
|
||||||
|
yBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yBegin
|
||||||
|
vBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
|
||||||
|
uBegin = round((syncPorch + lum + sep + porch + chrom + sep + porch) * sampleRate).toInt()
|
||||||
|
end = round((syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
|
||||||
|
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||||
|
ema.reset()
|
||||||
|
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until width) {
|
||||||
|
val yP = yBegin + (i * lumSamples) / width
|
||||||
|
val uP = uBegin + (i * chromSamples) / width
|
||||||
|
val vP = vBegin + (i * chromSamples) / width
|
||||||
|
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb(scratch[yP], scratch[uP], scratch[vP])
|
||||||
|
}
|
||||||
|
pixelBuffer.width = width; pixelBuffer.height = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PdMode(
|
||||||
|
variant: String,
|
||||||
|
override val visCode: Int,
|
||||||
|
private val hPixels: Int,
|
||||||
|
private val vPixels: Int,
|
||||||
|
channelSec: Double,
|
||||||
|
sampleRate: Int
|
||||||
|
) : SstvMode {
|
||||||
|
override val name = "PD $variant"
|
||||||
|
override val width get() = hPixels
|
||||||
|
override val height get() = vPixels
|
||||||
|
override val firstSyncPulseIndex = 0
|
||||||
|
override val scanLineSamples: Int
|
||||||
|
override val firstPixelSampleIndex: Int
|
||||||
|
|
||||||
|
private val chSamples: Int
|
||||||
|
private val yEvenBegin: Int
|
||||||
|
private val vAvgBegin: Int
|
||||||
|
private val uAvgBegin: Int
|
||||||
|
private val yOddBegin: Int
|
||||||
|
private val end: Int
|
||||||
|
private val ema: Ema
|
||||||
|
|
||||||
|
init {
|
||||||
|
val syncPorch = 0.00208
|
||||||
|
scanLineSamples = round((0.02 + syncPorch + 4 * channelSec) * sampleRate).toInt()
|
||||||
|
chSamples = round(channelSec * sampleRate).toInt()
|
||||||
|
yEvenBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yEvenBegin
|
||||||
|
vAvgBegin = round((syncPorch + channelSec) * sampleRate).toInt()
|
||||||
|
uAvgBegin = round((syncPorch + 2 * channelSec) * sampleRate).toInt()
|
||||||
|
yOddBegin = round((syncPorch + 3 * channelSec) * sampleRate).toInt()
|
||||||
|
end = round((syncPorch + 4 * channelSec) * sampleRate).toInt()
|
||||||
|
ema = Ema.withCutoff(hPixels.toDouble(), (2 * chSamples).toDouble(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||||
|
ema.reset()
|
||||||
|
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until hPixels) {
|
||||||
|
val pos = (i * chSamples) / hPixels
|
||||||
|
pixelBuffer.pixels[i] =
|
||||||
|
ColorConverter.yuv2rgb(scratch[pos + yEvenBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
|
||||||
|
pixelBuffer.pixels[i + hPixels] =
|
||||||
|
ColorConverter.yuv2rgb(scratch[pos + yOddBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
|
||||||
|
}
|
||||||
|
pixelBuffer.width = hPixels; pixelBuffer.height = 2
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
|
||||||
|
override val visCode = -1
|
||||||
|
override val width = -1
|
||||||
|
override val height = -1
|
||||||
|
override val firstPixelSampleIndex = 0
|
||||||
|
override val firstSyncPulseIndex = -1
|
||||||
|
override val scanLineSamples = -1
|
||||||
|
|
||||||
|
private val smallMax = round(0.125 * sampleRate).toInt()
|
||||||
|
private val medMax = round(0.175 * sampleRate).toInt()
|
||||||
|
private val ema = Ema()
|
||||||
|
|
||||||
|
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
|
||||||
|
var px = scopeWidth
|
||||||
|
if (lineSamples < smallMax) px /= 2
|
||||||
|
if (lineSamples < medMax) px /= 2
|
||||||
|
ema.setCutoff(px.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)
|
||||||
|
for (i in 0 until px) pixelBuffer.pixels[i] = ColorConverter.gray(scratch[(i * lineSamples) / px])
|
||||||
|
pixelBuffer.width = px; pixelBuffer.height = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun freqToLevel(frequency: Float, offset: Float): Float = 0.5f * (frequency - offset + 1f)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.usecase
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform abstraction for microphone audio capture.
|
||||||
|
* Produces a flow of mono Float PCM buffers at the configured sample rate.
|
||||||
|
*/
|
||||||
|
interface IAudioCapture {
|
||||||
|
val sampleRate: Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start capturing audio. Emits FloatArray buffers continuously until the flow is canceled.
|
||||||
|
* Caller is responsible for holding RECORD_AUDIO permission before calling this.
|
||||||
|
*/
|
||||||
|
fun audioFlow(): Flow<FloatArray>
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.usecase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a decoded SSTV image to the device gallery.
|
||||||
|
* @return true if the image was saved successfully
|
||||||
|
*/
|
||||||
|
interface ISaveImage {
|
||||||
|
suspend operator fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean
|
||||||
|
}
|
||||||
@@ -82,7 +82,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
argper = values[7].toDouble(),
|
argper = values[7].toDouble(),
|
||||||
meanan = values[8].toDouble(),
|
meanan = values[8].toDouble(),
|
||||||
catnum = values[11].toInt(),
|
catnum = values[11].toInt(),
|
||||||
bstar = values[14].toDouble()
|
bstar = values[14].toDouble(),
|
||||||
|
ndot = values[15].toDouble()
|
||||||
)
|
)
|
||||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
@@ -99,13 +100,15 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
argper = line2.substring(34, 42).toDouble(),
|
argper = line2.substring(34, 42).toDouble(),
|
||||||
meanan = line2.substring(43, 51).toDouble(),
|
meanan = line2.substring(43, 51).toDouble(),
|
||||||
catnum = line1.substring(2, 7).trim().toInt(),
|
catnum = line1.substring(2, 7).trim().toInt(),
|
||||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble())
|
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||||
|
ndot = line1.substring(33, 43).trim().toDouble()
|
||||||
)
|
)
|
||||||
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
val isLeapYear = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
||||||
val daysInMonth = intArrayOf(31, if (isLeapYear) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||||
|
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,10 +59,53 @@ class DataParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(parsedList[0].epoch == 21320.51955234)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(parsedList[1].epoch == 24069.23963816)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.eccn == 0.0004694)
|
||||||
|
assert(sat.incl == 51.6447)
|
||||||
|
assert(sat.raan == 309.4881)
|
||||||
|
assert(sat.argper == 203.6966)
|
||||||
|
assert(sat.meanan == 299.8876)
|
||||||
|
assert(sat.bstar == 0.31985E-4)
|
||||||
|
assert(sat.ndot == 0.1288E-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
// ISS is healthy, should not be decayed even years later
|
||||||
|
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
|
||||||
|
// Simulate a satellite with high drag and old epoch that should have decayed
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
|
||||||
|
assert(sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||||
@@ -71,10 +114,41 @@ class DataParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(parsedList[0].epoch == 21320.51955234)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(parsedList[1].epoch == 24069.23963816)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid TLE stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
ISS (ZARYA)
|
||||||
|
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.eccn == 0.0004694)
|
||||||
|
assert(sat.incl == 51.6447)
|
||||||
|
assert(sat.raan == 309.4881)
|
||||||
|
assert(sat.argper == 203.6966)
|
||||||
|
assert(sat.meanan == 299.8876)
|
||||||
|
assert(sat.ndot == 0.00001288)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid TLE stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
ISS (ZARYA)
|
||||||
|
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
||||||
@@ -85,6 +159,43 @@ class DataParserTest {
|
|||||||
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val jsonStream = """
|
||||||
|
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||||
|
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||||
|
assert(radio.info == "Upper side band (drifting)")
|
||||||
|
assert(radio.isAlive)
|
||||||
|
assert(radio.downlinkLow == 136658500L)
|
||||||
|
assert(radio.downlinkHigh == 136700000L)
|
||||||
|
assert(radio.downlinkMode == "USB")
|
||||||
|
assert(radio.uplinkLow == 145900000L)
|
||||||
|
assert(radio.uplinkHigh == 146000000L)
|
||||||
|
assert(radio.uplinkMode == "FM")
|
||||||
|
assert(radio.isInverted)
|
||||||
|
assert(radio.catnum == 965)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
|
||||||
|
val jsonStream = """
|
||||||
|
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||||
|
assert(radio.uuid == "abc123")
|
||||||
|
assert(!radio.isAlive)
|
||||||
|
assert(radio.downlinkLow == 145800000L)
|
||||||
|
assert(radio.downlinkHigh == null)
|
||||||
|
assert(radio.downlinkMode == null)
|
||||||
|
assert(radio.uplinkLow == null)
|
||||||
|
assert(radio.uplinkHigh == null)
|
||||||
|
assert(radio.uplinkMode == null)
|
||||||
|
assert(!radio.isInverted)
|
||||||
|
assert(radio.catnum == 12345)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
||||||
@@ -95,11 +206,37 @@ class DataParserTest {
|
|||||||
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Test
|
@Test
|
||||||
// fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
|
fun `isLeapYear returns correct results`() {
|
||||||
// val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
|
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
|
||||||
// val answers = listOf(false, true, false, true, false, true, false, true)
|
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
|
||||||
// val results = years.map { dataParser.isLeapYear(it) }
|
val results = years.map { dataParser.isLeapYear(it) }
|
||||||
// assert(results == answers)
|
assert(results == expected)
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for January 1st`() {
|
||||||
|
assert(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||||
|
assert(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
|
||||||
|
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61
|
||||||
|
assert(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||||
|
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
|
||||||
|
assert(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for December 31st`() {
|
||||||
|
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||||
|
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for November 16th`() {
|
||||||
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
|
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+101
-16
@@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -48,12 +49,22 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
@@ -151,16 +162,17 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_elevation),
|
painter = painterResource(R.drawable.ic_elevation),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = elevColor,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "${pass.maxElevation}°",
|
text = "${pass.maxElevation}°",
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = elevColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
@@ -178,12 +190,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -212,14 +218,11 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp)) {
|
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier
|
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||||
.clickable(onClick = action)
|
}
|
||||||
.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,3 +388,85 @@ fun TopBar(
|
|||||||
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun elevationColor(elevation: Double): Color {
|
||||||
|
return when {
|
||||||
|
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
|
||||||
|
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
|
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)
|
||||||
|
}
|
||||||
+31
-4
@@ -30,10 +30,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
@@ -41,3 +38,33 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object RadarDestination : NavKey
|
||||||
|
|
||||||
|
interface IDeeplinkMatcher {
|
||||||
|
fun match(deeplink: String): NavKey?
|
||||||
|
}
|
||||||
|
|
||||||
|
object PassDetailsMatcher : IDeeplinkMatcher {
|
||||||
|
val passDetailsRegex = """https://github.com/rt-bishop/Look4Sat/passes/(.*)""".toRegex()
|
||||||
|
|
||||||
|
override fun match(deeplink: String): NavKey? {
|
||||||
|
val passMatch = passDetailsRegex.find(deeplink)
|
||||||
|
passMatch?.let { match ->
|
||||||
|
val passId = match.groupValues[1]
|
||||||
|
if (passId.isNotEmpty()) return RadarDestination
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes) {
|
||||||
|
|
||||||
|
private val matchers: List<IDeeplinkMatcher> = listOf(PassDetailsMatcher)
|
||||||
|
|
||||||
|
fun resolve(deeplink: String): NavKey {
|
||||||
|
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
||||||
|
return fallbackDestination
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.dropShadow
|
||||||
|
import androidx.compose.ui.draw.innerShadow
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||||
|
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
LaunchedEffect(willTrigger) {
|
||||||
|
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||||
|
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
|
||||||
|
}
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
enableDismissFromStartToEnd = true,
|
||||||
|
enableDismissFromEndToStart = true,
|
||||||
|
backgroundContent = {
|
||||||
|
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||||
|
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||||
|
},
|
||||||
|
content = { content() },
|
||||||
|
onDismiss = {
|
||||||
|
val targetValue = dismissState.targetValue
|
||||||
|
when (targetValue) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
|
||||||
|
SwipeToDismissBoxValue.Settled -> {}
|
||||||
|
}
|
||||||
|
if (targetValue != SwipeToDismissBoxValue.Settled) {
|
||||||
|
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
|
||||||
|
val shape = RectangleShape
|
||||||
|
Box(
|
||||||
|
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(width = 1.dp, shape = shape, color = bgColor)
|
||||||
|
.dropShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) .2f else 0f
|
||||||
|
}
|
||||||
|
.innerShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) 1f else .2f
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||||
|
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_filter),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.rotate(if (isSwipeRight) 0f else 180f)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale
|
||||||
|
scaleY = iconScale
|
||||||
|
translationX = slide.toPx()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
|
||||||
|
</vector>
|
||||||
+1
-1
@@ -5,5 +5,5 @@
|
|||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
|
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M8,5v14l11,-7z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z" />
|
||||||
|
</vector>
|
||||||
@@ -49,10 +49,9 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added fixes for Chinese translation, by Mubi-Baihua (#216)
|
* Fixed transceivers retention on a failed fetch from server
|
||||||
\n* Added zipped data sources handling to DatabaseRepo
|
\n\n* Fixed SSTV frequency display, no transceiver selected hint
|
||||||
\n* Tweaked Passes list to show DeepSpace ones at the top
|
\n\n* Fixed All satellite category retention on Satellites screen
|
||||||
\n* Tweaked RadarView to display sun/moon positions correctly
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -185,12 +184,14 @@
|
|||||||
|
|
||||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat users and contributors!
|
* Look4Sat users and contributors!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n* David A. B. Johnson (predict4java)
|
||||||
\n• Dave Moten (predict4java)
|
\n* Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n* Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
|
\n* xdsopl and Robot36 contributors!
|
||||||
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
+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,4 +1,3 @@
|
|||||||
* Added fixes for Chinese translation, by Mubi-Baihua (#216)
|
* Fixed transceivers retention on a failed fetch from server
|
||||||
* Added zipped data sources handling to DatabaseRepo
|
* Fixed SSTV frequency display, no transceiver selected hint
|
||||||
* Tweaked Passes list to show DeepSpace ones at the top
|
* Fixed All satellite category retention on Satellites screen
|
||||||
* Tweaked RadarView to display sun/moon positions correctly
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
* Added fixes for Chinese translation, by Mubi-Baihua (#216)
|
|
||||||
* Added zipped data sources handling to DatabaseRepo
|
|
||||||
* Tweaked Passes list to show DeepSpace ones at the top
|
|
||||||
* Tweaked RadarView to display sun/moon positions correctly
|
|
||||||
@@ -123,10 +123,7 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|||||||
fun MapDestination() {
|
fun MapDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||||
modelClass = MapViewModel::class.java,
|
|
||||||
factory = MapViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val mapView = rememberMapViewWithLifecycle()
|
val mapView = rememberMapViewWithLifecycle()
|
||||||
MapScreen(uiState, viewModel::onAction, mapView)
|
MapScreen(uiState, viewModel::onAction, mapView)
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ class MapViewModel(
|
|||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectDefaultSatellite(-1)
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
|
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: MapAction) {
|
fun onAction(action: MapAction) {
|
||||||
@@ -126,10 +127,16 @@ class MapViewModel(
|
|||||||
val dateNow = Date()
|
val dateNow = Date()
|
||||||
getStationPosition()
|
getStationPosition()
|
||||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||||
|
// Scale update interval based on satellite count to avoid excessive CPU usage
|
||||||
|
val effectiveRate = when {
|
||||||
|
allSatellites.size > 5000 -> maxOf(updateFreq, 3000L)
|
||||||
|
allSatellites.size > 1000 -> maxOf(updateFreq, 2000L)
|
||||||
|
else -> updateFreq
|
||||||
|
}
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
dateNow.time = System.currentTimeMillis()
|
dateNow.time = System.currentTimeMillis()
|
||||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||||
delay(updateFreq)
|
delay(effectiveRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -54,6 +55,7 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
|||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
|
|
||||||
private val allModes = listOf(
|
private val allModes = listOf(
|
||||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
||||||
@@ -96,7 +98,8 @@ internal fun PassesDialog(
|
|||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
|
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
|
||||||
valueResId = R.drawable.ic_elevation,
|
valueResId = R.drawable.ic_elevation,
|
||||||
valueRange = 0f..60f
|
valueRange = 0f..60f,
|
||||||
|
accentColor = elevationColor(elevationValueNew.doubleValue)
|
||||||
) { elevationValueNew.doubleValue = it.toDouble() }
|
) { elevationValueNew.doubleValue = it.toDouble() }
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_hours),
|
title = stringResource(R.string.pass_filter_hours),
|
||||||
@@ -117,6 +120,7 @@ private fun SliderRow(
|
|||||||
valueResId: Int,
|
valueResId: Int,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
steps: Int = 0,
|
steps: Int = 0,
|
||||||
|
accentColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
onChange: (Float) -> Unit
|
onChange: (Float) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -137,14 +141,14 @@ private fun SliderRow(
|
|||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = valueResId),
|
painter = painterResource(id = valueResId),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = accentColor,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = displayValue,
|
text = displayValue,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = accentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.passes
|
package com.rtbishop.look4sat.feature.passes
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -67,8 +72,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
|
|||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -80,10 +87,7 @@ import java.util.TimeZone
|
|||||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||||
modelClass = PassesViewModel::class.java,
|
|
||||||
factory = PassesViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||||
}
|
}
|
||||||
@@ -143,9 +147,11 @@ private fun PassesScreen(
|
|||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
isUtc = uiState.isUtc,
|
isUtc = uiState.isUtc,
|
||||||
passes = uiState.itemsList,
|
passes = uiState.itemsList,
|
||||||
|
groupedPasses = uiState.groupedPasses,
|
||||||
|
sunTimes = uiState.sunTimes,
|
||||||
|
focusedCatNum = uiState.focusedCatNum,
|
||||||
navigateToRadar = navigateToRadar,
|
navigateToRadar = navigateToRadar,
|
||||||
refreshPasses = { onAction(PassesAction.RefreshPasses) },
|
onAction = onAction
|
||||||
sunTimes = uiState.sunTimes
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,33 +162,19 @@ private fun PassesList(
|
|||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
isUtc: Boolean,
|
isUtc: Boolean,
|
||||||
passes: List<OrbitalPass>,
|
passes: List<OrbitalPass>,
|
||||||
|
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||||
|
sunTimes: Map<String, Pair<String, String>>,
|
||||||
|
focusedCatNum: Int?,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
refreshPasses: () -> Unit,
|
onAction: (PassesAction) -> Unit
|
||||||
sunTimes: Map<String, Pair<String, String>>
|
|
||||||
) {
|
) {
|
||||||
val isVerticalLayout = isVerticalLayout()
|
val isVerticalLayout = isVerticalLayout()
|
||||||
val refreshState = rememberPullToRefreshState()
|
val refreshState = rememberPullToRefreshState()
|
||||||
val timeZone = remember(isUtc) { if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() }
|
|
||||||
val sdfDate = remember(isUtc) {
|
|
||||||
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep Space first, then date-grouped timed passes
|
|
||||||
val groupedPasses = remember(passes, isUtc) {
|
|
||||||
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
|
||||||
val deepSpace = passes.filter { it.isDeepSpace }
|
|
||||||
if (deepSpace.isNotEmpty()) ordered["Deep Space"] = deepSpace
|
|
||||||
passes.filter { !it.isDeepSpace }
|
|
||||||
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
|
|
||||||
.forEach { (k, v) -> ordered[k] = v }
|
|
||||||
ordered
|
|
||||||
}
|
|
||||||
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
onRefresh = refreshPasses,
|
onRefresh = { onAction(PassesAction.RefreshPasses) },
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
@@ -196,20 +188,36 @@ private fun PassesList(
|
|||||||
if (passes.isEmpty()) {
|
if (passes.isEmpty()) {
|
||||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
AnimatedContent(
|
||||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
targetState = focusedCatNum,
|
||||||
stickyHeader(key = "header_$dateLabel") {
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
label = "PassesFocusTransition"
|
||||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
) { targetFocus ->
|
||||||
}
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = dayPasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||||
PassItem(
|
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
|
||||||
pass = pass,
|
if (headerVisible) {
|
||||||
navigateToRadar = navigateToRadar,
|
stickyHeader(key = "header_$dateLabel") {
|
||||||
modifier = Modifier.animateItem(),
|
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||||
isVerticalLayout = isVerticalLayout,
|
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||||
isUtc = isUtc
|
}
|
||||||
)
|
}
|
||||||
|
val visiblePasses = if (targetFocus == null) dayPasses
|
||||||
|
else dayPasses.filter { it.catNum == targetFocus }
|
||||||
|
items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||||
|
SwipeableItem(
|
||||||
|
onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
|
||||||
|
onSwipeLeft = { onAction(PassesAction.ClearFocus) }
|
||||||
|
) {
|
||||||
|
PassItem(
|
||||||
|
pass = pass,
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
isVerticalLayout = isVerticalLayout,
|
||||||
|
isUtc = isUtc
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,16 +332,17 @@ private fun PassItem(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_elevation),
|
painter = painterResource(id = R.drawable.ic_elevation),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = elevColor,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "${pass.maxElevation}°",
|
text = "${pass.maxElevation}°",
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = elevColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
@@ -361,13 +370,6 @@ private fun PassItem(
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ data class PassesState(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val modes: List<String> = emptyList(),
|
val modes: List<String> = emptyList(),
|
||||||
val itemsList: List<OrbitalPass> = emptyList(),
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
|
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
||||||
val shouldSeeWhatsNew: Boolean = false,
|
val shouldSeeWhatsNew: Boolean = false,
|
||||||
// Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group
|
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
|
||||||
val sunTimes: Map<String, Pair<String, String>> = emptyMap()
|
val focusedCatNum: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface PassesAction {
|
sealed interface PassesAction {
|
||||||
@@ -44,4 +45,6 @@ sealed interface PassesAction {
|
|||||||
data object RefreshPasses : PassesAction
|
data object RefreshPasses : PassesAction
|
||||||
data object TogglePassesDialog : PassesAction
|
data object TogglePassesDialog : PassesAction
|
||||||
data object ToggleRadiosDialog : PassesAction
|
data object ToggleRadiosDialog : PassesAction
|
||||||
|
data class FocusCatNum(val catNum: Int) : PassesAction
|
||||||
|
data object ClearFocus : PassesAction
|
||||||
}
|
}
|
||||||
+40
-32
@@ -74,37 +74,31 @@ class PassesViewModel(
|
|||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Local tick loop — computes pass progress, countdown timer, and per-day sun times every second
|
// Main tick loop: reacts to new passes, then ticks every second
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
var lastSunTimesKey = "" // track when we need to recompute sun times
|
satelliteRepo.passes.collectLatest { allPasses ->
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val timeNow = System.currentTimeMillis()
|
val timeNow = System.currentTimeMillis()
|
||||||
val isUtc = _uiState.value.isUtc
|
val isUtc = _uiState.value.isUtc
|
||||||
val showDeepSpace = _uiState.value.showDeepSpace
|
val showDeepSpace = _uiState.value.showDeepSpace
|
||||||
val allPasses = satelliteRepo.passes.value
|
val filtered = allPasses
|
||||||
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace }
|
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||||
val processed = computePassProgress(filtered, timeNow)
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
|
val sunTimes = computeSunTimes(processed, isUtc)
|
||||||
// Recompute per-day sun times only when passes list or UTC setting changes
|
val grouped = groupPasses(processed, isUtc)
|
||||||
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc"
|
_uiState.update {
|
||||||
val sunTimes = if (sunTimesKey != lastSunTimesKey) {
|
it.copy(
|
||||||
lastSunTimesKey = sunTimesKey
|
itemsList = processed,
|
||||||
computeSunTimes(processed, isUtc)
|
groupedPasses = grouped,
|
||||||
} else {
|
sunTimes = sunTimes,
|
||||||
_uiState.value.sunTimes
|
nextPass = nextPass,
|
||||||
|
nextTime = nextTime,
|
||||||
|
isNextTimeAos = isAos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
itemsList = processed,
|
|
||||||
nextPass = nextPass,
|
|
||||||
nextTime = nextTime,
|
|
||||||
isNextTimeAos = isAos,
|
|
||||||
sunTimes = sunTimes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,22 +115,24 @@ class PassesViewModel(
|
|||||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
PassesAction.ToggleRadiosDialog ->
|
PassesAction.ToggleRadiosDialog ->
|
||||||
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
||||||
|
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
|
||||||
|
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for Deep Space
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// Deep Space group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result["Deep Space"] = rise to set
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
}
|
}
|
||||||
for (pass in passes) {
|
for (pass in passes) {
|
||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
@@ -150,6 +146,18 @@ class PassesViewModel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
|
passes.filter { !it.isDeepSpace }
|
||||||
|
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
|
||||||
|
.forEach { (k, v) -> ordered[k] = v }
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
/** Computes live progress for each pass, filtering out expired ones. */
|
/** Computes live progress for each pass, filtering out expired ones. */
|
||||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||||
val result = ArrayList<OrbitalPass>(passList.size)
|
val result = ArrayList<OrbitalPass>(passList.size)
|
||||||
|
|||||||
@@ -17,15 +17,17 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -33,65 +35,67 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.keepScreenOn
|
import androidx.compose.ui.keepScreenOn
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private enum class RadarPage(val title: String) {
|
||||||
|
Transceivers("Transceivers"),
|
||||||
|
Sstv("SSTV")
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarDestination(
|
fun RadarDestination(navigateUp: () -> Unit) {
|
||||||
catNum: Int = 0,
|
|
||||||
aosTime: Long = 0L,
|
|
||||||
navigateUp: () -> Unit,
|
|
||||||
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
modelClass = RadarViewModel::class.java,
|
|
||||||
key = "$catNum-$aosTime",
|
|
||||||
factory = RadarViewModel.factory(catNum, aosTime, container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl)
|
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||||
|
val hasPermission = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.RECORD_AUDIO
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
LaunchedEffect(hasPermission) {
|
||||||
|
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
|
||||||
|
}
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
|
||||||
|
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
|
||||||
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -99,20 +103,11 @@ private fun RadarScreen(
|
|||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
navigateToRadioControl: (Int, Long) -> Unit
|
requestMicPermission: () -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
LaunchedEffect(uiState.isLos) { if (uiState.isLos) navigateUp() }
|
|
||||||
|
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
uiState.currentPass?.let { pass ->
|
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||||
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val openRadioControl: () -> Unit = {
|
|
||||||
uiState.currentPass?.let { pass ->
|
|
||||||
navigateToRadioControl(pass.catNum, pass.aosTime)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -123,26 +118,71 @@ private fun RadarScreen(
|
|||||||
val isVertical = isVerticalLayout()
|
val isVertical = isVerticalLayout()
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, Modifier.weight(1f))
|
||||||
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, Modifier.weight(1f))
|
||||||
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PagerCard(
|
||||||
|
uiState: RadarState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
requestMicPermission: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val pages = RadarPage.entries
|
||||||
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
ElevatedCard(modifier = modifier) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
||||||
|
pages.forEachIndexed { index, page ->
|
||||||
|
Tab(
|
||||||
|
selected = pagerState.currentPage == index,
|
||||||
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { pageIndex ->
|
||||||
|
when (pages[pageIndex]) {
|
||||||
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,25 +191,26 @@ private fun RadarScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||||
val satellitePos = uiState.orbitalPos
|
val satellitePos = uiState.orbitalPos
|
||||||
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
|
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
// Always call these composables unconditionally — conditional composable calls violate
|
||||||
val borderAlpha by infiniteTransition.animateFloat(
|
// Compose's slot-table stability rules and can crash or produce incorrect state
|
||||||
initialValue = 1.0f,
|
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
||||||
targetValue = 0.0f,
|
val borderAlpha by infiniteTransition.animateFloat(
|
||||||
animationSpec = infiniteRepeatable(
|
initialValue = 1.0f,
|
||||||
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
targetValue = 0.0f,
|
||||||
repeatMode = RepeatMode.Reverse
|
animationSpec = infiniteRepeatable(
|
||||||
),
|
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
||||||
label = "eclipsedBorderAlpha"
|
repeatMode = RepeatMode.Reverse
|
||||||
)
|
),
|
||||||
|
label = "eclipsedBorderAlpha"
|
||||||
|
)
|
||||||
|
val borderModifier = if (shouldAnimateBorder) {
|
||||||
Modifier.border(
|
Modifier.border(
|
||||||
width = 0.5.dp,
|
width = 0.5.dp,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
||||||
shape = MaterialTheme.shapes.medium
|
shape = MaterialTheme.shapes.medium
|
||||||
)
|
)
|
||||||
} else {
|
} else Modifier
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
val position = uiState.orbitalPos
|
val position = uiState.orbitalPos
|
||||||
@@ -256,181 +297,3 @@ private fun RadarLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmittersCard(
|
|
||||||
transmitters: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
onAction: (RadarAction) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = modifier) {
|
|
||||||
if (transmitters.isEmpty()) {
|
|
||||||
EmptyTransmittersContent()
|
|
||||||
} else {
|
|
||||||
TransmittersList(
|
|
||||||
transmitters = transmitters,
|
|
||||||
selectedUuid = selectedUuid,
|
|
||||||
onSelect = { uuid ->
|
|
||||||
if (selectedUuid != null) {
|
|
||||||
onAction(RadarAction.SelectTransmitter(uuid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EmptyTransmittersContent() {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
modifier = Modifier.padding(32.dp)
|
|
||||||
) {
|
|
||||||
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.empty_list_message),
|
|
||||||
fontSize = 21.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.radar_no_data),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmittersList(
|
|
||||||
transmitters: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
onSelect: (String) -> Unit
|
|
||||||
) {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
items(items = transmitters, key = { it.uuid }) { radio ->
|
|
||||||
TransmitterItem(
|
|
||||||
radio = radio,
|
|
||||||
isClickable = selectedUuid != null,
|
|
||||||
isSelected = radio.uuid == selectedUuid,
|
|
||||||
onClick = { onSelect(radio.uuid) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun TransmitterItemPreview() {
|
|
||||||
val transmitter = SatRadio(
|
|
||||||
"", "Extremely powerful transmitter", true, 10000000000L, 10000000000L,
|
|
||||||
null, 10000000000L, 10000000000L, "FSK AX.100 Mode 5", true, 0
|
|
||||||
)
|
|
||||||
MainTheme { TransmitterItem(transmitter, isClickable = true, isSelected = true, onClick = {}) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmitterItem(
|
|
||||||
radio: SatRadio,
|
|
||||||
isClickable: Boolean,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
|
|
||||||
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(if (isClickable) Modifier.clickable { onClick() } else Modifier)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
Text(
|
|
||||||
text = fullTitle,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 6.dp)
|
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
|
||||||
if (isSelected) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_radios),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FrequencyRow(radio = radio, isDownlink = true)
|
|
||||||
FrequencyRow(radio = radio, isDownlink = false)
|
|
||||||
}
|
|
||||||
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
val desc = if (isDownlink) stringResource(R.string.radar_downlink)
|
|
||||||
else stringResource(R.string.radar_uplink)
|
|
||||||
Text(
|
|
||||||
text = if (isDownlink) "D:" else "U:",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.semantics { contentDescription = desc }
|
|
||||||
)
|
|
||||||
FrequencyText(
|
|
||||||
frequency = if (isDownlink) radio.downlinkLow else radio.uplinkLow,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "-",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 21.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
FrequencyText(
|
|
||||||
frequency = if (isDownlink) radio.downlinkHigh else radio.uplinkHigh,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_arrow),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.rotate(if (isDownlink) 90f else -90f)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
|
||||||
val text = frequency?.let {
|
|
||||||
stringResource(id = R.string.radar_link_low, it / 1000000f)
|
|
||||||
} ?: stringResource(R.string.radar_no_link)
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 21.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -21,12 +21,36 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
||||||
|
|
||||||
|
data class RadioPanelState(
|
||||||
|
val label: String = "",
|
||||||
|
val isConnected: Boolean = false,
|
||||||
|
val frequencyHz: Long? = null,
|
||||||
|
val frequencyDisplay: String = "---",
|
||||||
|
val mode: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RadioControlSubState(
|
||||||
|
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
||||||
|
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
||||||
|
val selectedTransponderUuid: String? = null,
|
||||||
|
val txBaseFrequencyHz: Long? = null,
|
||||||
|
val ctcssTone: Double? = null,
|
||||||
|
val isTracking: Boolean = false,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TransceiverSubState(
|
||||||
|
val transmitters: List<SatRadio> = emptyList(),
|
||||||
|
val selectedUuid: String? = null,
|
||||||
|
val selectedFrequency: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class RadarState(
|
data class RadarState(
|
||||||
val currentPass: OrbitalPass? = null,
|
val currentPass: OrbitalPass? = null,
|
||||||
val currentTime: String = "00:00:00",
|
val currentTime: String = "00:00:00",
|
||||||
val isTimeAos: Boolean = true,
|
val isTimeAos: Boolean = true,
|
||||||
val isLos: Boolean = false,
|
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
||||||
val orbitalPos: OrbitalPos? = null,
|
val orbitalPos: OrbitalPos? = null,
|
||||||
@@ -35,12 +59,39 @@ data class RadarState(
|
|||||||
val shouldUseCompass: Boolean = false,
|
val shouldUseCompass: Boolean = false,
|
||||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
val transmitters: List<SatRadio> = emptyList(),
|
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||||
val selectedTransmitterUuid: String? = null,
|
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||||
val selectedFrequency: Long? = null
|
val sstv: SstvSubState = SstvSubState()
|
||||||
|
)
|
||||||
|
|
||||||
|
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(),
|
||||||
|
val currentFrame: SstvFrame? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface RadarAction {
|
sealed interface RadarAction {
|
||||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
||||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||||
|
|
||||||
|
// Radio control actions
|
||||||
|
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
||||||
|
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
||||||
|
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
||||||
|
data object ToggleTracking : RadarAction
|
||||||
|
data object ConnectRadios : RadarAction
|
||||||
|
data object DisconnectRadios : RadarAction
|
||||||
|
|
||||||
|
// SSTV actions
|
||||||
|
data object SstvStartRecording : RadarAction
|
||||||
|
data object SstvStopRecording : RadarAction
|
||||||
|
data object SstvSaveImage : RadarAction
|
||||||
|
data object SstvReset : RadarAction
|
||||||
|
data class SstvSelectMode(val modeName: String) : RadarAction
|
||||||
|
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.graphics.Path
|
|||||||
import androidx.compose.ui.graphics.PathEffect
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
import androidx.compose.ui.graphics.PathMeasure
|
import androidx.compose.ui.graphics.PathMeasure
|
||||||
import androidx.compose.ui.graphics.ShaderBrush
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.StampedPathEffectStyle
|
import androidx.compose.ui.graphics.StampedPathEffectStyle
|
||||||
import androidx.compose.ui.graphics.SweepGradientShader
|
import androidx.compose.ui.graphics.SweepGradientShader
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
@@ -63,7 +65,7 @@ import kotlin.math.sin
|
|||||||
|
|
||||||
private const val CIRCLES = 3
|
private const val CIRCLES = 3
|
||||||
private const val STROKE_WIDTH = 6f
|
private const val STROKE_WIDTH = 6f
|
||||||
private const val SWEEP_INCREMENT = 360f / 12f / 60f
|
private const val SWEEP_DURATION_MS = 8_000
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarViewCompose(
|
fun RadarViewCompose(
|
||||||
@@ -87,23 +89,44 @@ fun RadarViewCompose(
|
|||||||
animationSpec = infiniteRepeatable(tween(1000)),
|
animationSpec = infiniteRepeatable(tween(1000)),
|
||||||
label = "animScale"
|
label = "animScale"
|
||||||
)
|
)
|
||||||
|
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
|
||||||
|
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||||
|
val sweepDegrees by sweepTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||||
|
label = "sweepDegrees"
|
||||||
|
)
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
val sunPainter = painterResource(R.drawable.ic_sun)
|
val sunPainter = painterResource(R.drawable.ic_sun)
|
||||||
val moonPainter = painterResource(R.drawable.ic_moon)
|
val moonPainter = painterResource(R.drawable.ic_moon)
|
||||||
var sweepDegrees by remember { mutableFloatStateOf(0f) }
|
// Track path cache — keyed by both canvas size and items reference so it rebuilds
|
||||||
|
// when the satellite track data arrives asynchronously after the first composition
|
||||||
var cachedRadius by remember { mutableFloatStateOf(0f) }
|
var cachedRadius by remember { mutableFloatStateOf(0f) }
|
||||||
|
var cachedItemsRef by remember { mutableStateOf<List<OrbitalPos>>(emptyList()) }
|
||||||
|
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
||||||
var trackPath by remember { mutableStateOf(Path()) }
|
var trackPath by remember { mutableStateOf(Path()) }
|
||||||
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
||||||
|
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
||||||
|
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
||||||
|
|
||||||
Canvas(modifier = modifier.aspectRatio(1f)) {
|
Canvas(modifier = modifier.aspectRatio(1f)) {
|
||||||
val radius = size.minDimension / 2f * 0.95f
|
val radius = size.minDimension / 2f * 0.95f
|
||||||
if (radius != cachedRadius) {
|
// Rebuild track path and sweep brush when canvas size or track data changes
|
||||||
|
if (radius != cachedRadius || items !== cachedItemsRef) {
|
||||||
trackPath = createTrackPath(items, radius)
|
trackPath = createTrackPath(items, radius)
|
||||||
trackEffect = createTrackEffect(trackPath)
|
trackEffect = createTrackEffect(trackPath)
|
||||||
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedRadius = radius
|
cachedRadius = radius
|
||||||
|
cachedItemsRef = items
|
||||||
|
cachedSweepColor = primaryColor
|
||||||
|
} else if (primaryColor != cachedSweepColor) {
|
||||||
|
// Rebuild brush on theme change without waiting for a size change
|
||||||
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
|
cachedSweepColor = primaryColor
|
||||||
}
|
}
|
||||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
||||||
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor)
|
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||||
drawRadar(radius, radarColor)
|
drawRadar(radius, radarColor)
|
||||||
drawElevationLabels(radius, primaryColor, measurer)
|
drawElevationLabels(radius, primaryColor, measurer)
|
||||||
translate(center.x, center.y) {
|
translate(center.x, center.y) {
|
||||||
@@ -126,7 +149,6 @@ fun RadarViewCompose(
|
|||||||
}
|
}
|
||||||
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
|
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
|
||||||
}
|
}
|
||||||
sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,10 +193,13 @@ private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Co
|
|||||||
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
|
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
|
private fun makeSweepBrush(center: Offset, color: Color): ShaderBrush {
|
||||||
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
||||||
val colorStops = listOf(0.64f, 0.995f, 1f)
|
val colorStops = listOf(0.64f, 0.995f, 1f)
|
||||||
val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
return ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, brush: ShaderBrush) {
|
||||||
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +245,7 @@ private fun DrawScope.drawBodyIcon(
|
|||||||
translate(pos.x - half, pos.y - half)
|
translate(pos.x - half, pos.y - half)
|
||||||
}) {
|
}) {
|
||||||
with(painter) {
|
with(painter) {
|
||||||
draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color))
|
draw(Size(iconSize, iconSize), colorFilter = ColorFilter.tint(color))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+254
-82
@@ -24,16 +24,24 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -41,83 +49,163 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class RadarViewModel(
|
class RadarViewModel(
|
||||||
private val catNum: Int,
|
|
||||||
private val aosTime: Long,
|
|
||||||
private val bluetoothReporter: IReporter,
|
private val bluetoothReporter: IReporter,
|
||||||
private val networkReporter: IReporter,
|
private val networkReporter: IReporter,
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val sensorsRepo: ISensorsRepo,
|
private val sensorsRepo: ISensorsRepo,
|
||||||
private val addToCalendar: IAddToCalendar
|
private val addToCalendar: IAddToCalendar,
|
||||||
|
private val trackingService: IRadioTrackingService,
|
||||||
|
private val audioCapture: IAudioCapture,
|
||||||
|
private val saveImage: ISaveImage,
|
||||||
|
private val showToast: IShowToast
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
private val stationPos = settingsRepo.stationPosition.value
|
||||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||||
|
private var transponders: List<SatRadio> = emptyList()
|
||||||
|
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(
|
private val _uiState = MutableStateFlow(
|
||||||
RadarState(
|
RadarState(
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
orientationValues = sensorsRepo.orientation.value,
|
orientationValues = sensorsRepo.sensorData.value,
|
||||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors
|
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||||
|
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val uiState: StateFlow<RadarState> = _uiState
|
val uiState: StateFlow<RadarState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Compass sensor collection
|
collectCompassSensor()
|
||||||
if (settingsRepo.otherSettings.value.stateOfSensors) {
|
collectSettingsChanges()
|
||||||
viewModelScope.launch {
|
collectPassAndStartTickLoop()
|
||||||
sensorsRepo.enableSensor()
|
collectRadioTrackingState()
|
||||||
sensorsRepo.orientation.collect { data ->
|
}
|
||||||
val orientationValues = (data.first + magDeclination) to data.second
|
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
private fun collectCompassSensor() {
|
||||||
}
|
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
sensorsRepo.enableSensor()
|
||||||
|
sensorsRepo.sensorData.collect { data ->
|
||||||
|
val orientationValues = (data.first + magDeclination) to data.second
|
||||||
|
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// React to UTC setting changes
|
}
|
||||||
|
|
||||||
|
private fun collectSettingsChanges() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUtc = settings.stateOfUtc,
|
||||||
|
shouldShowSweep = settings.stateOfSweep,
|
||||||
|
shouldUseCompass = settings.stateOfSensors
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Resolve which pass we're tracking and start the tick loop
|
}
|
||||||
|
|
||||||
|
// --- Pass loading split into focused functions ---
|
||||||
|
|
||||||
|
private fun collectPassAndStartTickLoop() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val passes = satelliteRepo.passes.value
|
val pass = findCurrentPass() ?: return@launch
|
||||||
val currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
val allRadios = loadPassData(pass)
|
||||||
?: passes.firstOrNull()
|
while (isActive) {
|
||||||
currentPass?.let { satPass ->
|
tickPass(pass, allRadios)
|
||||||
_uiState.update { it.copy(currentPass = satPass) }
|
delay(1000.milliseconds)
|
||||||
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
|
}
|
||||||
// Compute track once (it doesn't change for a given pass)
|
}
|
||||||
if (!satPass.isDeepSpace) {
|
}
|
||||||
val track = satelliteRepo.getTrack(
|
|
||||||
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
|
private fun findCurrentPass(): OrbitalPass? {
|
||||||
)
|
val passes = satelliteRepo.passes.value
|
||||||
_uiState.update { it.copy(satTrack = track) }
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
}
|
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||||
// Tick loop — position, timer, and radio updates every second
|
?: passes.firstOrNull()
|
||||||
while (isActive) {
|
}
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
// Loads transmitters and satellite track for pass, sets initial state, returns full radio list
|
||||||
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
private suspend fun loadPassData(pass: OrbitalPass): List<SatRadio> {
|
||||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
_uiState.update { it.copy(currentPass = pass) }
|
||||||
val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
|
val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||||
val isLos = !satPass.isDeepSpace && timeNow > satPass.losTime
|
transponders = allRadios.filter { it.downlinkLow != null }
|
||||||
_uiState.update {
|
if (allRadios.isNotEmpty()) {
|
||||||
it.copy(
|
val firstUuid = allRadios.first().uuid
|
||||||
currentTime = time,
|
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
|
||||||
isTimeAos = isAos,
|
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||||
isLos = isLos,
|
}
|
||||||
orbitalPos = pos,
|
if (!pass.isDeepSpace) {
|
||||||
sunPosition = sunPos,
|
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||||
moonPosition = moonPos
|
_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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
currentTime = time, isTimeAos = isAos,
|
||||||
|
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
processRadios(allRadios, pass.orbitalObject, timeNow)
|
||||||
|
sendPassData(pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectRadioTrackingState() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
trackingService.state.collect { svc ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
radioControl = state.radioControl.copy(
|
||||||
|
txPanel = RadioPanelState(
|
||||||
|
label = "TX (Uplink)",
|
||||||
|
isConnected = svc.txConnected,
|
||||||
|
frequencyHz = svc.txFrequencyHz,
|
||||||
|
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||||
|
mode = svc.txMode
|
||||||
|
),
|
||||||
|
rxPanel = RadioPanelState(
|
||||||
|
label = "RX (Downlink)",
|
||||||
|
isConnected = svc.rxConnected,
|
||||||
|
frequencyHz = svc.rxFrequencyHz,
|
||||||
|
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||||
|
mode = svc.rxMode
|
||||||
|
),
|
||||||
|
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
||||||
|
ctcssTone = svc.ctcssTone,
|
||||||
|
isTracking = svc.isActive,
|
||||||
|
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
||||||
|
errorMessage = svc.errorMessage
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
processRadios(transmitters, satPass.orbitalObject, timeNow)
|
|
||||||
sendPassData(pos)
|
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +219,59 @@ class RadarViewModel(
|
|||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
||||||
is RadarAction.SelectTransmitter -> _uiState.update { it.copy(selectedTransmitterUuid = action.uuid) }
|
is RadarAction.SelectTransmitter -> {
|
||||||
|
// 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||||
|
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||||
|
is RadarAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
||||||
|
RadarAction.ToggleTracking -> {
|
||||||
|
val svc = trackingService.state.value
|
||||||
|
if (svc.isActive) {
|
||||||
|
trackingService.stopTracking()
|
||||||
|
} else {
|
||||||
|
val pass = _uiState.value.currentPass ?: return
|
||||||
|
val transponder = svc.selectedTransponder ?: return
|
||||||
|
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
||||||
|
RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
||||||
|
|
||||||
|
// SSTV actions
|
||||||
|
is RadarAction.SstvPermissionResult -> {
|
||||||
|
_uiState.update { it.copy(sstv = it.sstv.copy(hasPermission = action.granted)) }
|
||||||
|
if (action.granted) initSstvDecoder()
|
||||||
|
}
|
||||||
|
RadarAction.SstvStartRecording -> startSstvRecording()
|
||||||
|
RadarAction.SstvStopRecording -> stopSstvRecording()
|
||||||
|
RadarAction.SstvSaveImage -> {
|
||||||
|
val frame = _uiState.value.sstv.currentFrame ?: return
|
||||||
|
val pixels = frame.imagePixels ?: return
|
||||||
|
_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(isSaving = false)) }
|
||||||
|
showToast("Image saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is RadarAction.SstvSelectMode -> {
|
||||||
|
sstvDecoder?.lockMode(action.modeName)
|
||||||
|
_uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
|
||||||
|
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
||||||
|
}
|
||||||
|
RadarAction.SstvReset -> {
|
||||||
|
sstvDecoder?.clearPixels()
|
||||||
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,13 +305,14 @@ class RadarViewModel(
|
|||||||
frequencyEnabled: Boolean,
|
frequencyEnabled: Boolean,
|
||||||
frequencyFormat: String
|
frequencyFormat: String
|
||||||
) {
|
) {
|
||||||
if (rotatorEnabled) {
|
// Only send rotator commands when the satellite is above the horizon
|
||||||
|
if (rotatorEnabled && orbitalPos.aboveHorizon) {
|
||||||
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
||||||
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
||||||
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
||||||
}
|
}
|
||||||
if (frequencyEnabled) {
|
if (frequencyEnabled) {
|
||||||
_uiState.value.selectedFrequency?.let { freq ->
|
_uiState.value.transceivers.selectedFrequency?.let { freq ->
|
||||||
reporter.reportFrequency(frequencyFormat, freq)
|
reporter.reportFrequency(frequencyFormat, freq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,51 +320,82 @@ class RadarViewModel(
|
|||||||
|
|
||||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
||||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
||||||
val isFreqEnabled =
|
|
||||||
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
if (!isFreqEnabled) {
|
val freq = if (state.transceivers.selectedUuid != null) {
|
||||||
// Skip update if nothing changed
|
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||||
if (state.transmitters == transmitters && state.selectedTransmitterUuid == null && state.selectedFrequency == null) {
|
selectedRadio?.let { radio ->
|
||||||
return@update state
|
val low = radio.downlinkLow
|
||||||
|
val high = radio.downlinkHigh
|
||||||
|
when {
|
||||||
|
low != null && high != null -> (low + high) / 2
|
||||||
|
low != null -> low
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return@update state.copy(
|
} else null
|
||||||
transmitters = transmitters,
|
|
||||||
selectedTransmitterUuid = null,
|
val current = state.transceivers
|
||||||
selectedFrequency = null
|
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
|
||||||
)
|
|
||||||
}
|
|
||||||
val selectedUuid = state.selectedTransmitterUuid ?: transmitters.firstOrNull()?.uuid
|
|
||||||
val selectedRadio = transmitters.firstOrNull { it.uuid == selectedUuid }
|
|
||||||
val freq = selectedRadio?.let { radio ->
|
|
||||||
val low = radio.downlinkLow
|
|
||||||
val high = radio.downlinkHigh
|
|
||||||
when {
|
|
||||||
low != null && high != null -> (low + high) / 2
|
|
||||||
low != null -> low
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skip update if nothing changed
|
|
||||||
if (state.transmitters == transmitters && state.selectedTransmitterUuid == selectedUuid && state.selectedFrequency == freq) {
|
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(transmitters = transmitters, selectedTransmitterUuid = selectedUuid, selectedFrequency = freq)
|
state.copy(transceivers = current.copy(transmitters = transmitters, selectedFrequency = freq))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun initSstvDecoder() {
|
||||||
|
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 ->
|
||||||
|
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSstvRecording() {
|
||||||
|
if (sstvRecordingJob?.isActive == true) return
|
||||||
|
initSstvDecoder()
|
||||||
|
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Recording)) }
|
||||||
|
sstvRecordingJob = viewModelScope.launch {
|
||||||
|
audioCapture.audioFlow().collect { buffer ->
|
||||||
|
sstvDecoder?.feedSamples(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopSstvRecording() {
|
||||||
|
sstvRecordingJob?.cancel()
|
||||||
|
sstvRecordingJob = null
|
||||||
|
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory {
|
|
||||||
|
val CTCSS_TONES = listOf(
|
||||||
|
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||||
|
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
||||||
|
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
||||||
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
catNum = catNum,
|
|
||||||
aosTime = aosTime,
|
|
||||||
bluetoothReporter = container.provideBluetoothReporter(),
|
bluetoothReporter = container.provideBluetoothReporter(),
|
||||||
networkReporter = container.provideNetworkReporter(),
|
networkReporter = container.provideNetworkReporter(),
|
||||||
satelliteRepo = container.satelliteRepo,
|
satelliteRepo = container.satelliteRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
sensorsRepo = container.provideSensorsRepo(),
|
sensorsRepo = container.provideSensorsRepo(),
|
||||||
addToCalendar = container.provideAddToCalendar()
|
addToCalendar = container.provideAddToCalendar(),
|
||||||
|
trackingService = container.radioTrackingService,
|
||||||
|
audioCapture = container.provideAudioCapture(),
|
||||||
|
saveImage = container.provideSaveImage(),
|
||||||
|
showToast = container.provideShowToast()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import 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
|
||||||
|
) {
|
||||||
|
if (!sstv.hasPermission) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(text = "🎙️", fontSize = 48.sp)
|
||||||
|
Text(
|
||||||
|
text = "Microphone access needed",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Button(onClick = requestMicPermission) {
|
||||||
|
Text("Grant permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode selection dialog
|
||||||
|
val showModeDialog = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showModeDialog.value) {
|
||||||
|
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||||
|
Dialog(onDismissRequest = { showModeDialog.value = false }) {
|
||||||
|
ElevatedCard {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "SSTV Mode",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight(0.5f)
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
|
) {
|
||||||
|
items(allModes) { mode ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clickable {
|
||||||
|
onAction(RadarAction.SstvSelectMode(mode))
|
||||||
|
showModeDialog.value = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = mode,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
RadioButton(
|
||||||
|
selected = mode == sstv.selectedMode,
|
||||||
|
onClick = null,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main SSTV view — image fills entire area, controls overlay at bottom
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
// Decoded image or placeholder — centered in available space
|
||||||
|
val frame = sstv.currentFrame
|
||||||
|
val pixels = frame?.imagePixels
|
||||||
|
if (pixels != null && frame.imageWidth > 0 && frame.imageHeight > 0) {
|
||||||
|
val bitmap = remember(pixels, frame.imageWidth, frame.imageHeight) {
|
||||||
|
Bitmap.createBitmap(
|
||||||
|
pixels,
|
||||||
|
frame.imageWidth,
|
||||||
|
frame.imageHeight,
|
||||||
|
Bitmap.Config.ARGB_8888
|
||||||
|
).asImageBitmap()
|
||||||
|
}
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap,
|
||||||
|
contentDescription = "Decoded SSTV image",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Bottom control bar — dark, blends with the black canvas
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
// Doppler-corrected downlink frequency hint
|
||||||
|
OutlinedText(
|
||||||
|
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fillColor = MaterialTheme.colorScheme.primary,
|
||||||
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
// Mode button — opens dialog
|
||||||
|
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 && !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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.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(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
selectedUuid: String?,
|
||||||
|
radioControl: RadioControlSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (transceivers.isEmpty()) {
|
||||||
|
EmptyTransceiversContent(modifier)
|
||||||
|
} else {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
// Snap the expanded item to the top of the visible area
|
||||||
|
LaunchedEffect(selectedUuid) {
|
||||||
|
if (selectedUuid != null) {
|
||||||
|
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
||||||
|
if (index >= 0) {
|
||||||
|
kotlinx.coroutines.delay(300.milliseconds)
|
||||||
|
listState.animateScrollToItem(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
|
||||||
|
itemsIndexed(items = transceivers, key = { _, radio -> radio.uuid }) { _, radio ->
|
||||||
|
val isExpanded = radio.uuid == selectedUuid
|
||||||
|
TransceiverItem(
|
||||||
|
radio = radio,
|
||||||
|
isExpanded = isExpanded,
|
||||||
|
radioControl = radioControl,
|
||||||
|
onAction = onAction,
|
||||||
|
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
modifier = Modifier.padding(32.dp)
|
||||||
|
) {
|
||||||
|
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.empty_list_message),
|
||||||
|
fontSize = 21.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.radar_no_data),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransceiverItem(
|
||||||
|
radio: SatRadio,
|
||||||
|
isExpanded: Boolean,
|
||||||
|
radioControl: RadioControlSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
onToggle: () -> Unit
|
||||||
|
) {
|
||||||
|
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(bgColor)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { onToggle() }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
// Header: [arrow slot] [name - (mode)] [icon slot]
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
// Left icon slot — always reserved
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.rotate(if (isExpanded) 270f else 90f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Title with mode
|
||||||
|
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||||
|
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||||
|
val fullTitle = "$title ($mode)"
|
||||||
|
Text(
|
||||||
|
text = fullTitle,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.infiniteMarquee()
|
||||||
|
)
|
||||||
|
// Right arrow slot — always reserved
|
||||||
|
val iconTint = if (isExpanded) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.outlineVariant
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_radios),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency rows
|
||||||
|
UnifiedFrequencyRow(
|
||||||
|
label = "TX",
|
||||||
|
frequencyLow = radio.uplinkLow,
|
||||||
|
frequencyHigh = radio.uplinkHigh,
|
||||||
|
isConnected = if (isExpanded) radioControl.txPanel.isConnected else null
|
||||||
|
)
|
||||||
|
UnifiedFrequencyRow(
|
||||||
|
label = "RX",
|
||||||
|
frequencyLow = radio.downlinkLow,
|
||||||
|
frequencyHigh = radio.downlinkHigh,
|
||||||
|
isConnected = if (isExpanded) radioControl.rxPanel.isConnected else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded CAT control area
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically()
|
||||||
|
) {
|
||||||
|
ExpandedRadioControl(
|
||||||
|
radio = radio,
|
||||||
|
radioControl = radioControl,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnifiedFrequencyRow(
|
||||||
|
label: String,
|
||||||
|
frequencyLow: Long?,
|
||||||
|
frequencyHigh: Long?,
|
||||||
|
isConnected: Boolean?
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
// TX:/RX: label
|
||||||
|
Text(
|
||||||
|
text = "$label:",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(24.dp)
|
||||||
|
)
|
||||||
|
// Low frequency
|
||||||
|
FrequencyText(
|
||||||
|
frequency = frequencyLow,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
// Dash — always centered
|
||||||
|
Text(
|
||||||
|
text = "–",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(16.dp)
|
||||||
|
)
|
||||||
|
// High frequency
|
||||||
|
FrequencyText(
|
||||||
|
frequency = frequencyHigh,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
// Connection dot on the right
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.width(24.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
when (isConnected) {
|
||||||
|
true -> Color(0xFF4CAF50)
|
||||||
|
false -> Color(0xFFE57373)
|
||||||
|
null -> MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExpandedRadioControl(
|
||||||
|
radio: SatRadio,
|
||||||
|
radioControl: RadioControlSubState,
|
||||||
|
onAction: (RadarAction) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frequency tuner
|
||||||
|
if (radioControl.txBaseFrequencyHz != null) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "TX Base: ",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val upLow = radio.uplinkLow
|
||||||
|
val upHigh = radio.uplinkHigh
|
||||||
|
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||||
|
Text(
|
||||||
|
text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CTCSS (only for FM uplink)
|
||||||
|
if (radio.uplinkMode?.uppercase() == "FM") {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "CTCSS",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val chipModifier = Modifier.width(64.dp)
|
||||||
|
FilterChip(
|
||||||
|
selected = radioControl.ctcssTone == null,
|
||||||
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = "Off",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = chipModifier
|
||||||
|
)
|
||||||
|
RadarViewModel.CTCSS_TONES.forEach { tone ->
|
||||||
|
FilterChip(
|
||||||
|
selected = radioControl.ctcssTone == tone,
|
||||||
|
onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = String.format(Locale.ENGLISH, "%.1f", tone),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = chipModifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||||
|
text = "Connect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||||
|
text = "Disconnect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||||
|
text = if (radioControl.isTracking) "Stop" else "Track",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
radioControl.errorMessage?.let { msg ->
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
|
Text(text = msg, color = MaterialTheme.colorScheme.error, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||||
|
val text = frequency?.let {
|
||||||
|
stringResource(id = R.string.radar_link_low, it / 1000000f)
|
||||||
|
} ?: stringResource(R.string.radar_no_link)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FREQ_ADJUSTMENTS =
|
||||||
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.convention.featurePlugin)
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.rtbishop.look4sat.feature.radiocontrol"
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
-396
@@ -1,396 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.feature.radiocontrol
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.keepScreenOn
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
|
||||||
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.getDefaultPass
|
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RadioControlDestination(catNum: Int = 0, aosTime: Long = 0L, navigateUp: () -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
|
||||||
val viewModel = viewModel(
|
|
||||||
modelClass = RadioControlViewModel::class.java,
|
|
||||||
key = "$catNum-$aosTime",
|
|
||||||
factory = RadioControlViewModel.factory(catNum, aosTime, container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadioControlScreen(
|
|
||||||
uiState: RadioControlState,
|
|
||||||
onAction: (RadioControlAction) -> Unit,
|
|
||||||
navigateUp: () -> Unit
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.layoutPadding()
|
|
||||||
.keepScreenOn(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
val currentPass = uiState.currentPass ?: getDefaultPass()
|
|
||||||
val isVertical = isVerticalLayout()
|
|
||||||
if (isVertical) {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
}
|
|
||||||
TopBar { NextPassRow(pass = currentPass) }
|
|
||||||
} else {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
|
||||||
NextPassRow(pass = currentPass, modifier = Modifier.weight(1f))
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RadioPanel(panel = uiState.txPanel)
|
|
||||||
RadioPanel(panel = uiState.rxPanel)
|
|
||||||
|
|
||||||
PositionRow(
|
|
||||||
azimuth = uiState.azimuth,
|
|
||||||
elevation = uiState.elevation,
|
|
||||||
distance = uiState.distance
|
|
||||||
)
|
|
||||||
|
|
||||||
val selectedTransponder = uiState.transponders.find {
|
|
||||||
it.uuid == uiState.selectedTransponderUuid
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
TransponderSelector(
|
|
||||||
transponders = uiState.transponders,
|
|
||||||
selectedUuid = uiState.selectedTransponderUuid,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
|
|
||||||
item {
|
|
||||||
CtcssSelector(
|
|
||||||
ctcssTone = uiState.ctcssTone,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.txBaseFrequencyHz != null) {
|
|
||||||
item {
|
|
||||||
FrequencyTuner(
|
|
||||||
txBaseFrequencyHz = uiState.txBaseFrequencyHz,
|
|
||||||
selectedTransponder = selectedTransponder,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.errorMessage?.let { msg ->
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = msg,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ControlButtons(
|
|
||||||
isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected,
|
|
||||||
isTracking = uiState.isTracking,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadioPanel(panel: RadioPanelState) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (panel.isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(text = panel.label, fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = panel.frequencyDisplay,
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = panel.mode ?: "--",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PositionRow(azimuth: String, elevation: String, distance: String) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = "Az", fontSize = 12.sp)
|
|
||||||
Text(text = "$azimuth°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = "El", fontSize = 12.sp)
|
|
||||||
Text(text = "$elevation°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = "Dist", fontSize = 12.sp)
|
|
||||||
Text(text = "$distance km", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransponderSelector(
|
|
||||||
transponders: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
if (transponders.isEmpty()) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = "No transponders with uplink+downlink available",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
|
||||||
transponders.forEach { radio ->
|
|
||||||
TransponderItem(
|
|
||||||
radio = radio,
|
|
||||||
isSelected = radio.uuid == selectedUuid,
|
|
||||||
onSelect = { onAction(RadioControlAction.SelectTransponder(radio.uuid)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -> Unit) {
|
|
||||||
val invLabel = if (radio.isInverted) " INV" else ""
|
|
||||||
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
|
|
||||||
Surface(
|
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surface,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onSelect() }
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(text = radio.info, modifier = Modifier.weight(1f))
|
|
||||||
Text(text = modeLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun CtcssSelector(
|
|
||||||
ctcssTone: Double?,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
|
|
||||||
Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = ctcssTone == null,
|
|
||||||
onClick = { onAction(RadioControlAction.SetCtcssTone(null)) },
|
|
||||||
label = { Text("Off") }
|
|
||||||
)
|
|
||||||
RadioControlViewModel.CTCSS_TONES.forEach { tone ->
|
|
||||||
FilterChip(
|
|
||||||
selected = ctcssTone == tone,
|
|
||||||
onClick = { onAction(RadioControlAction.SetCtcssTone(tone)) },
|
|
||||||
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val FREQ_ADJUSTMENTS =
|
|
||||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyTuner(
|
|
||||||
txBaseFrequencyHz: Long,
|
|
||||||
selectedTransponder: SatRadio?,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary)
|
|
||||||
if (selectedTransponder != null) {
|
|
||||||
val upLow = selectedTransponder.uplinkLow
|
|
||||||
val upHigh = selectedTransponder.uplinkHigh
|
|
||||||
val dnLow = selectedTransponder.downlinkLow
|
|
||||||
val dnHigh = selectedTransponder.downlinkHigh
|
|
||||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
|
||||||
Text(
|
|
||||||
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (dnLow != null && dnHigh != null && dnLow != dnHigh) {
|
|
||||||
Text(
|
|
||||||
text = "DN: ${RadioControlViewModel.formatFrequency(dnLow)} - ${RadioControlViewModel.formatFrequency(dnHigh)}",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${RadioControlViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.AdjustTxFrequency(delta)) },
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ControlButtons(
|
|
||||||
isConnected: Boolean,
|
|
||||||
isTracking: Boolean,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
if (!isConnected) {
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.ConnectRadios) },
|
|
||||||
text = "Connect",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.DisconnectRadios) },
|
|
||||||
text = "Disconnect",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.ToggleTracking) },
|
|
||||||
text = if (isTracking) "Stop Tracking" else "Start Tracking",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.feature.radiocontrol
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|
||||||
|
|
||||||
data class RadioPanelState(
|
|
||||||
val label: String,
|
|
||||||
val isConnected: Boolean,
|
|
||||||
val frequencyHz: Long?,
|
|
||||||
val frequencyDisplay: String,
|
|
||||||
val mode: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RadioControlState(
|
|
||||||
val currentPass: OrbitalPass?,
|
|
||||||
val currentTime: String,
|
|
||||||
val isCurrentTimeAos: Boolean,
|
|
||||||
val azimuth: String,
|
|
||||||
val elevation: String,
|
|
||||||
val distance: String,
|
|
||||||
val txPanel: RadioPanelState,
|
|
||||||
val rxPanel: RadioPanelState,
|
|
||||||
val transponders: List<SatRadio>,
|
|
||||||
val selectedTransponderUuid: String?,
|
|
||||||
val txBaseFrequencyHz: Long?,
|
|
||||||
val ctcssTone: Double?,
|
|
||||||
val isTracking: Boolean,
|
|
||||||
val errorMessage: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed interface RadioControlAction {
|
|
||||||
data class SelectTransponder(val uuid: String) : RadioControlAction
|
|
||||||
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction
|
|
||||||
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction
|
|
||||||
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction
|
|
||||||
data object ToggleTracking : RadioControlAction
|
|
||||||
data object ConnectRadios : RadioControlAction
|
|
||||||
data object DisconnectRadios : RadioControlAction
|
|
||||||
}
|
|
||||||
-202
@@ -1,202 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.feature.radiocontrol
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class RadioControlViewModel(
|
|
||||||
private val catNum: Int,
|
|
||||||
private val aosTime: Long,
|
|
||||||
private val trackingService: IRadioTrackingService,
|
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
|
||||||
settingsRepo: ISettingsRepo
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
|
||||||
private var currentPass: OrbitalPass? = null
|
|
||||||
private var transponders: List<SatRadio> = emptyList()
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
RadioControlState(
|
|
||||||
currentPass = null,
|
|
||||||
currentTime = "00:00:00",
|
|
||||||
isCurrentTimeAos = true,
|
|
||||||
azimuth = "0.0",
|
|
||||||
elevation = "0.0",
|
|
||||||
distance = "0.0",
|
|
||||||
txPanel = RadioPanelState("TX (Uplink)", false, null, "---", null),
|
|
||||||
rxPanel = RadioPanelState("RX (Downlink)", false, null, "---", null),
|
|
||||||
transponders = emptyList(),
|
|
||||||
selectedTransponderUuid = null,
|
|
||||||
txBaseFrequencyHz = null,
|
|
||||||
ctcssTone = null,
|
|
||||||
isTracking = false,
|
|
||||||
errorMessage = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val uiState: StateFlow<RadioControlState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Resolve pass and load transponders
|
|
||||||
viewModelScope.launch {
|
|
||||||
val passes = satelliteRepo.passes.value
|
|
||||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
|
||||||
?: passes.firstOrNull()
|
|
||||||
currentPass = pass
|
|
||||||
pass?.let { satPass ->
|
|
||||||
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum)
|
|
||||||
transponders = allRadios.filter { it.downlinkLow != null }
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(currentPass = satPass, transponders = transponders)
|
|
||||||
}
|
|
||||||
// If service is already tracking this pass, sync the selected transponder
|
|
||||||
val svcState = trackingService.state.value
|
|
||||||
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Tick loop — timer and satellite position updates every second
|
|
||||||
while (isActive) {
|
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
|
||||||
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
currentTime = timeStr,
|
|
||||||
isCurrentTimeAos = isAos,
|
|
||||||
azimuth = String.format(Locale.ENGLISH, "%.1f", pos.azimuth.toDegrees()),
|
|
||||||
elevation = String.format(Locale.ENGLISH, "%.1f", pos.elevation.toDegrees()),
|
|
||||||
distance = String.format(Locale.ENGLISH, "%.0f", pos.distance)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe service state for radio-specific updates (panels, frequencies, tracking status)
|
|
||||||
viewModelScope.launch {
|
|
||||||
trackingService.state.collect { svc ->
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
txPanel = RadioPanelState(
|
|
||||||
label = "TX (Uplink)",
|
|
||||||
isConnected = svc.txConnected,
|
|
||||||
frequencyHz = svc.txFrequencyHz,
|
|
||||||
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
|
||||||
mode = svc.txMode
|
|
||||||
),
|
|
||||||
rxPanel = RadioPanelState(
|
|
||||||
label = "RX (Downlink)",
|
|
||||||
isConnected = svc.rxConnected,
|
|
||||||
frequencyHz = svc.rxFrequencyHz,
|
|
||||||
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
|
||||||
mode = svc.rxMode
|
|
||||||
),
|
|
||||||
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
|
||||||
ctcssTone = svc.ctcssTone,
|
|
||||||
isTracking = svc.isActive,
|
|
||||||
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
|
||||||
errorMessage = svc.errorMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeTimer(isDeepSpace: Boolean, aosTime: Long, losTime: Long, timeNow: Long): Pair<String, Boolean> {
|
|
||||||
return when {
|
|
||||||
isDeepSpace -> 0L.toTimerString() to false
|
|
||||||
aosTime > timeNow -> (aosTime - timeNow).toTimerString() to true
|
|
||||||
else -> (losTime - timeNow).toTimerString() to false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onAction(action: RadioControlAction) {
|
|
||||||
when (action) {
|
|
||||||
is RadioControlAction.SelectTransponder -> {
|
|
||||||
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
|
||||||
trackingService.setTransponder(transponder)
|
|
||||||
}
|
|
||||||
is RadioControlAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
|
||||||
is RadioControlAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
|
||||||
is RadioControlAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
|
||||||
RadioControlAction.ToggleTracking -> {
|
|
||||||
val svc = trackingService.state.value
|
|
||||||
if (svc.isActive) {
|
|
||||||
trackingService.stopTracking()
|
|
||||||
} else {
|
|
||||||
val pass = currentPass ?: return
|
|
||||||
val transponder = svc.selectedTransponder ?: return
|
|
||||||
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RadioControlAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
|
||||||
RadioControlAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
val CTCSS_TONES = listOf(
|
|
||||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
|
||||||
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
|
||||||
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
|
||||||
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(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
RadioControlViewModel(
|
|
||||||
catNum = catNum,
|
|
||||||
aosTime = aosTime,
|
|
||||||
trackingService = container.radioTrackingService,
|
|
||||||
satelliteRepo = container.satelliteRepo,
|
|
||||||
settingsRepo = container.settingsRepo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-4
@@ -75,10 +75,7 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
|
|||||||
fun SatellitesDestination(navigateUp: () -> Unit) {
|
fun SatellitesDestination(navigateUp: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: SatellitesViewModel = viewModel(factory = SatellitesViewModel.factory(container))
|
||||||
modelClass = SatellitesViewModel::class.java,
|
|
||||||
factory = SatellitesViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
|
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-16
@@ -81,10 +81,7 @@ import java.util.Locale
|
|||||||
fun SettingsDestination() {
|
fun SettingsDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||||
modelClass = SettingsViewModel::class.java,
|
|
||||||
factory = SettingsViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction)
|
SettingsScreen(uiState, viewModel::onAction)
|
||||||
}
|
}
|
||||||
@@ -188,32 +185,63 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
val gitHubUrl = stringResource(R.string.prefs_github_url)
|
val gitHubUrl = stringResource(R.string.prefs_github_url)
|
||||||
val licenseUrl = stringResource(R.string.prefs_license_url)
|
val licenseUrl = stringResource(R.string.prefs_license_url)
|
||||||
val privacyUrl = stringResource(R.string.prefs_privacy_url)
|
val privacyUrl = stringResource(R.string.prefs_privacy_url)
|
||||||
|
val safeOpenUri: (String) -> Unit = { url ->
|
||||||
|
try { uriHandler.openUri(url) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
if (isVerticalLayout) {
|
if (isVerticalLayout) {
|
||||||
TopBar {
|
TopBar {
|
||||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
TopCard(
|
||||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
onClick = { safeOpenUri(appUrl) },
|
||||||
|
version = uiState.appVersionName,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
}
|
}
|
||||||
TopBar {
|
TopBar {
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
BotCard(
|
||||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
onClick = { safeOpenUri(fdroidUrl) },
|
||||||
|
resId = R.drawable.ic_fdroid,
|
||||||
|
text = fdroidTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
BotCard(
|
||||||
|
onClick = { safeOpenUri(gitHubUrl) },
|
||||||
|
resId = R.drawable.ic_github,
|
||||||
|
text = gitHubTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
TopCard(
|
||||||
|
onClick = { safeOpenUri(appUrl) },
|
||||||
|
version = uiState.appVersionName,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
BotCard(
|
||||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
onClick = { safeOpenUri(fdroidUrl) },
|
||||||
|
resId = R.drawable.ic_fdroid,
|
||||||
|
text = fdroidTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
BotCard(
|
||||||
|
onClick = { safeOpenUri(gitHubUrl) },
|
||||||
|
resId = R.drawable.ic_github,
|
||||||
|
text = gitHubTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "431"
|
appVersionCode = "442"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.3.1"
|
appVersionName = "4.4.2"
|
||||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
||||||
compileSdk = "36"
|
compileSdk = "36"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -12,24 +12,24 @@ jdkVersion = "17"
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.2.0"
|
android-gradle-plugin = "9.2.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.18.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.04.01"
|
compose-bom = "2026.06.00"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.10.0"
|
compose-lifecycle = "2.10.0"
|
||||||
compose-navigation3 = "1.1.1"
|
compose-navigation3 = "1.1.3"
|
||||||
|
|
||||||
google-ksp = "2.3.6"
|
google-ksp = "2.3.8"
|
||||||
|
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.0"
|
||||||
kotlin-coroutines = "1.10.2"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
other-okhttp = "5.3.2"
|
other-okhttp = "5.4.0"
|
||||||
other-osmdroid = "6.1.20"
|
other-osmdroid = "6.1.20"
|
||||||
|
|
||||||
test-junit4 = "4.13.2"
|
test-junit4 = "4.13.2"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Tue Apr 28 15:46:23 BST 2026
|
#Sun Jun 21 14:57:04 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ include(
|
|||||||
":feature:map",
|
":feature:map",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:radiocontrol",
|
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user