mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782b438a07 | ||
|
|
fac319b215 | ||
|
|
b7ac9c913a | ||
|
|
de3d5a6d84 | ||
|
|
5e39c945b2 | ||
|
|
1d07f76ada | ||
|
|
d471f02370 | ||
|
|
9699d142fa | ||
|
|
6f3e3351c9 | ||
|
|
d4a2fae0cd | ||
|
|
d0bbb97f29 | ||
|
|
c2fc5688b9 | ||
|
|
796056a582 | ||
|
|
9cac33dae7 | ||
|
|
082266e385 | ||
|
|
a42a5f1f0d |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import android.view.View
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
@@ -48,8 +50,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
|
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
val otherSettings by settingsRepo.otherSettings.collectAsState()
|
||||||
|
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
@@ -98,29 +99,43 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||||
slideOutHorizontally(tween(300)) { it }
|
slideOutHorizontally(tween(300)) { it }
|
||||||
NavDisplay(
|
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||||
modifier = Modifier.fillMaxSize(),
|
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||||
backStack = rootBackStack,
|
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||||
onBack = navigateBack,
|
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||||
transitionSpec = { pushTransition },
|
val context = LocalContext.current
|
||||||
popTransitionSpec = { popTransition },
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
predictivePopTransitionSpec = { popTransition },
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
entryDecorators = listOf(
|
CompositionLocalProvider(
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
low = otherSettings.lowElevation,
|
||||||
),
|
high = otherSettings.highElevation
|
||||||
entryProvider = entryProvider {
|
)
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
) {
|
||||||
entry<RadarDestination> {
|
NavDisplay(
|
||||||
Surface(
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
backStack = rootBackStack,
|
||||||
color = MaterialTheme.colorScheme.background
|
onBack = navigateBack,
|
||||||
) {
|
transitionSpec = { pushTransition },
|
||||||
RadarDestination(navigateUp = navigateBack)
|
popTransitionSpec = { popTransition },
|
||||||
|
predictivePopTransitionSpec = { popTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -128,7 +143,6 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
@@ -138,88 +152,81 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val navItems =
|
val navItems =
|
||||||
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
CompositionLocalProvider(
|
NavigationSuiteScaffold(
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
navigationSuiteItems = {
|
||||||
low = otherSettings.lowElevation,
|
navItems.forEach { screen ->
|
||||||
high = otherSettings.highElevation
|
val isSelected = when (currentKey) {
|
||||||
)
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
) {
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
NavigationSuiteScaffold(
|
is Screen.Status -> screen is Screen.Status
|
||||||
navigationSuiteItems = {
|
is Screen.Map -> screen is Screen.Map
|
||||||
navItems.forEach { screen ->
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
val isSelected = when (currentKey) {
|
else -> false
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
|
||||||
is Screen.Status -> screen is Screen.Status
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
item(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(screen.iconResId),
|
|
||||||
contentDescription = stringResource(screen.titleResId)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = {
|
|
||||||
if (isSelected) return@item
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
item(
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
icon = {
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
Icon(
|
||||||
),
|
painter = painterResource(screen.iconResId),
|
||||||
layoutType = when {
|
contentDescription = stringResource(screen.titleResId)
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
)
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
},
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
}
|
selected = isSelected,
|
||||||
) {
|
onClick = {
|
||||||
Column {
|
if (isSelected) return@item
|
||||||
NavDisplay(
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
backStack = backStack,
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
onBack = navigateBack,
|
|
||||||
transitionSpec = { fadeTransition },
|
|
||||||
popTransitionSpec = { fadeTransition },
|
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
|
||||||
entryDecorators = listOf(
|
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Satellites> {
|
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
entry<Screen.Passes> {
|
|
||||||
PassesDestination { catNum, aosTime ->
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
|
||||||
navigateToRadar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry<Screen.Status> { SatStatusDestination() }
|
|
||||||
entry<Screen.Map> { MapDestination() }
|
|
||||||
entry<Screen.Settings> { SettingsDestination() }
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (trackingState.isActive) {
|
}
|
||||||
TrackingBanner(
|
},
|
||||||
state = trackingState,
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
onClick = {
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
val pass = trackingState.currentPass
|
),
|
||||||
if (pass != null) {
|
layoutType = when {
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
navigateToRadar()
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
}
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
NavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { fadeTransition },
|
||||||
|
popTransitionSpec = { fadeTransition },
|
||||||
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
entry<Screen.Status> { SatStatusDestination() }
|
||||||
|
entry<Screen.Map> { MapDestination() }
|
||||||
|
entry<Screen.Settings> { SettingsDestination() }
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
TrackingBanner(
|
||||||
|
state = trackingState,
|
||||||
|
onClick = {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,7 +259,7 @@ private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Tracking: ${state.currentPass?.name ?: ""}",
|
text = stringResource(R.string.tracking_format, state.currentPass?.name ?: ""),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
|||||||
@@ -5,6 +5,46 @@ plugins {
|
|||||||
alias(libs.plugins.google.ksp) apply false
|
alias(libs.plugins.google.ksp) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.other.detekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
|
||||||
|
description = "Checks that source code satisfies detekt rules."
|
||||||
|
autoCorrect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
|
||||||
|
description = "Applies code formatting rules to source code in-place."
|
||||||
|
autoCorrect = true
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(listOf(detektCheck, detektApply)) {
|
||||||
|
configure {
|
||||||
|
group = "verification"
|
||||||
|
parallel = true
|
||||||
|
ignoreFailures = false
|
||||||
|
setSource(file(rootDir))
|
||||||
|
|
||||||
|
// Custom detekt config
|
||||||
|
config.setFrom("$projectDir/config/detekt/detekt.yml")
|
||||||
|
|
||||||
|
// Use default configuration on top of custom config
|
||||||
|
// (new detect rules will work out of the box after upgrading detekt version)
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
|
||||||
|
// Runs detekt for all files in the Gradle project and all subprojects without
|
||||||
|
// a need to configure detekt plugin in every subproject.
|
||||||
|
include("**/*.kt", "**/*.kts")
|
||||||
|
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
|
||||||
|
|
||||||
|
reports {
|
||||||
|
html.required.set(true)
|
||||||
|
xml.required.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
detektPlugins(libs.other.detekt.formatting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
build:
|
||||||
|
maxIssues: 0
|
||||||
|
excludeCorrectable: false
|
||||||
|
weights:
|
||||||
|
# complexity: 2
|
||||||
|
# LongParameterList: 1
|
||||||
|
# style: 1
|
||||||
|
# comments: 1
|
||||||
|
|
||||||
|
config:
|
||||||
|
validation: true
|
||||||
|
warningsAsErrors: false
|
||||||
|
checkExhaustiveness: false
|
||||||
|
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
|
||||||
|
excludes: ''
|
||||||
|
|
||||||
|
processors:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'DetektProgressListener'
|
||||||
|
# - 'KtFileCountProcessor'
|
||||||
|
# - 'PackageCountProcessor'
|
||||||
|
# - 'ClassCountProcessor'
|
||||||
|
# - 'FunctionCountProcessor'
|
||||||
|
# - 'PropertyCountProcessor'
|
||||||
|
# - 'ProjectComplexityProcessor'
|
||||||
|
# - 'ProjectCognitiveComplexityProcessor'
|
||||||
|
# - 'ProjectLLOCProcessor'
|
||||||
|
# - 'ProjectCLOCProcessor'
|
||||||
|
# - 'ProjectLOCProcessor'
|
||||||
|
# - 'ProjectSLOCProcessor'
|
||||||
|
# - 'LicenseHeaderLoaderExtension'
|
||||||
|
|
||||||
|
console-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'ProjectStatisticsReport'
|
||||||
|
- 'ComplexityReport'
|
||||||
|
- 'NotificationReport'
|
||||||
|
- 'FindingsReport'
|
||||||
|
- 'FileBasedFindingsReport'
|
||||||
|
# - 'LiteFindingsReport'
|
||||||
|
|
||||||
|
output-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
# - 'TxtOutputReport'
|
||||||
|
# - 'XmlOutputReport'
|
||||||
|
- 'HtmlOutputReport'
|
||||||
|
# - 'MdOutputReport'
|
||||||
|
# - 'SarifOutputReport'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
active: true
|
||||||
|
AbsentOrWrongFileLicense:
|
||||||
|
active: false
|
||||||
|
licenseTemplateFile: 'license.template'
|
||||||
|
licenseTemplateIsRegex: false
|
||||||
|
CommentOverPrivateFunction:
|
||||||
|
active: false
|
||||||
|
CommentOverPrivateProperty:
|
||||||
|
active: false
|
||||||
|
DeprecatedBlockTag:
|
||||||
|
active: false
|
||||||
|
EndOfSentenceFormat:
|
||||||
|
active: false
|
||||||
|
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
|
||||||
|
KDocReferencesNonPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
OutdatedDocumentation:
|
||||||
|
active: false
|
||||||
|
matchTypeParameters: true
|
||||||
|
matchDeclarationsOrder: true
|
||||||
|
allowParamOnConstructorProperties: false
|
||||||
|
UndocumentedPublicClass:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchInNestedClass: true
|
||||||
|
searchInInnerClass: true
|
||||||
|
searchInInnerObject: true
|
||||||
|
searchInInnerInterface: true
|
||||||
|
searchInProtectedClass: false
|
||||||
|
UndocumentedPublicFunction:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedFunction: false
|
||||||
|
UndocumentedPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedProperty: false
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
active: true
|
||||||
|
CognitiveComplexMethod:
|
||||||
|
active: false
|
||||||
|
threshold: 15
|
||||||
|
ComplexCondition:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
ComplexInterface:
|
||||||
|
active: false
|
||||||
|
threshold: 10
|
||||||
|
includeStaticDeclarations: false
|
||||||
|
includePrivateDeclarations: false
|
||||||
|
ignoreOverloaded: false
|
||||||
|
CyclomaticComplexMethod:
|
||||||
|
active: true
|
||||||
|
threshold: 15
|
||||||
|
ignoreSingleWhenExpression: false
|
||||||
|
ignoreSimpleWhenEntries: false
|
||||||
|
ignoreNestingFunctions: false
|
||||||
|
nestingFunctions:
|
||||||
|
- 'also'
|
||||||
|
- 'apply'
|
||||||
|
- 'forEach'
|
||||||
|
- 'isNotNull'
|
||||||
|
- 'ifNull'
|
||||||
|
- 'let'
|
||||||
|
- 'run'
|
||||||
|
- 'use'
|
||||||
|
- 'with'
|
||||||
|
# Composables describe UI trees declaratively and naturally accumulate more branches than
|
||||||
|
# regular functions. Exclude them here and rely on code review + decomposition instead.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
LabeledExpression:
|
||||||
|
active: false
|
||||||
|
ignoredLabels: [ ]
|
||||||
|
LargeClass:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 600
|
||||||
|
LongMethod:
|
||||||
|
active: true
|
||||||
|
# Composable UI functions are declarative trees; 60 lines is too strict for them.
|
||||||
|
# Screen/Slots files are excluded — keep them reasonable via code review.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 60
|
||||||
|
LongParameterList:
|
||||||
|
active: false
|
||||||
|
functionThreshold: 6
|
||||||
|
constructorThreshold: 7
|
||||||
|
ignoreDefaultParameters: false
|
||||||
|
ignoreDataClasses: true
|
||||||
|
ignoreAnnotatedParameter: [ ]
|
||||||
|
MethodOverloading:
|
||||||
|
active: false
|
||||||
|
threshold: 6
|
||||||
|
NamedArguments:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ignoreArgumentsMatchingNames: false
|
||||||
|
NestedBlockDepth:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
NestedScopeFunctions:
|
||||||
|
active: false
|
||||||
|
threshold: 1
|
||||||
|
functions:
|
||||||
|
- 'kotlin.apply'
|
||||||
|
- 'kotlin.run'
|
||||||
|
- 'kotlin.with'
|
||||||
|
- 'kotlin.let'
|
||||||
|
- 'kotlin.also'
|
||||||
|
ReplaceSafeCallChainWithRun:
|
||||||
|
active: false
|
||||||
|
StringLiteralDuplication:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
threshold: 3
|
||||||
|
ignoreAnnotation: true
|
||||||
|
excludeStringsWithLessThan5Characters: true
|
||||||
|
ignoreStringsRegex: '$^'
|
||||||
|
TooManyFunctions:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
thresholdInFiles: 11
|
||||||
|
thresholdInClasses: 11
|
||||||
|
thresholdInInterfaces: 11
|
||||||
|
thresholdInObjects: 11
|
||||||
|
thresholdInEnums: 11
|
||||||
|
ignoreDeprecated: false
|
||||||
|
ignorePrivate: false
|
||||||
|
ignoreOverridden: false
|
||||||
|
|
||||||
|
coroutines:
|
||||||
|
active: true
|
||||||
|
GlobalCoroutineUsage:
|
||||||
|
active: false
|
||||||
|
InjectDispatcher:
|
||||||
|
active: true
|
||||||
|
dispatcherNames:
|
||||||
|
- 'IO'
|
||||||
|
- 'Default'
|
||||||
|
- 'Unconfined'
|
||||||
|
RedundantSuspendModifier:
|
||||||
|
active: true
|
||||||
|
SleepInsteadOfDelay:
|
||||||
|
active: true
|
||||||
|
SuspendFunSwallowedCancellation:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithCoroutineScopeReceiver:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithFlowReturnType:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
empty-blocks:
|
||||||
|
active: true
|
||||||
|
EmptyCatchBlock:
|
||||||
|
active: true
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
EmptyClassBlock:
|
||||||
|
active: true
|
||||||
|
EmptyDefaultConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyDoWhileBlock:
|
||||||
|
active: true
|
||||||
|
EmptyElseBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFinallyBlock:
|
||||||
|
active: true
|
||||||
|
EmptyForBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFunctionBlock:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
EmptyIfBlock:
|
||||||
|
active: true
|
||||||
|
EmptyInitBlock:
|
||||||
|
active: true
|
||||||
|
EmptyKtFile:
|
||||||
|
active: true
|
||||||
|
EmptySecondaryConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyTryBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhenBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhileBlock:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
active: true
|
||||||
|
ExceptionRaisedInUnexpectedLocation:
|
||||||
|
active: true
|
||||||
|
methodNames:
|
||||||
|
- 'equals'
|
||||||
|
- 'finalize'
|
||||||
|
- 'hashCode'
|
||||||
|
- 'toString'
|
||||||
|
InstanceOfCheckForException:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
NotImplementedDeclaration:
|
||||||
|
active: false
|
||||||
|
ObjectExtendsThrowable:
|
||||||
|
active: false
|
||||||
|
PrintStackTrace:
|
||||||
|
active: true
|
||||||
|
RethrowCaughtException:
|
||||||
|
active: true
|
||||||
|
ReturnFromFinally:
|
||||||
|
active: true
|
||||||
|
ignoreLabeled: false
|
||||||
|
SwallowedException:
|
||||||
|
active: true
|
||||||
|
ignoredExceptionTypes:
|
||||||
|
- 'InterruptedException'
|
||||||
|
- 'MalformedURLException'
|
||||||
|
- 'NumberFormatException'
|
||||||
|
- 'ParseException'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
ThrowingExceptionFromFinally:
|
||||||
|
active: true
|
||||||
|
ThrowingExceptionInMain:
|
||||||
|
active: false
|
||||||
|
ThrowingExceptionsWithoutMessageOrCause:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptions:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalArgumentException'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IllegalStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
ThrowingNewInstanceOfSameException:
|
||||||
|
active: true
|
||||||
|
TooGenericExceptionCaught:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptionNames:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
TooGenericExceptionThrown:
|
||||||
|
active: true
|
||||||
|
exceptionNames:
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
|
||||||
|
formatting:
|
||||||
|
active: true
|
||||||
|
ArgumentListWrapping:
|
||||||
|
active: false
|
||||||
|
ParameterListWrapping:
|
||||||
|
active: false
|
||||||
|
NoWildcardImports:
|
||||||
|
active: false
|
||||||
|
MaximumLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
|
||||||
|
naming:
|
||||||
|
active: true
|
||||||
|
BooleanPropertyNaming:
|
||||||
|
active: false
|
||||||
|
allowedPattern: '^(is|has|are)'
|
||||||
|
ClassNaming:
|
||||||
|
active: true
|
||||||
|
classPattern: '[A-Z][a-zA-Z0-9_]*'
|
||||||
|
ConstructorParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
privateParameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
EnumNaming:
|
||||||
|
active: true
|
||||||
|
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
|
||||||
|
ForbiddenClassName:
|
||||||
|
active: false
|
||||||
|
forbiddenName: [ ]
|
||||||
|
FunctionMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumFunctionNameLength: 30
|
||||||
|
FunctionMinLength:
|
||||||
|
active: false
|
||||||
|
minimumFunctionNameLength: 3
|
||||||
|
FunctionNaming:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
FunctionParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
InvalidPackageDeclaration:
|
||||||
|
active: true
|
||||||
|
rootPackage: ''
|
||||||
|
requireRootInDeclaration: false
|
||||||
|
LambdaParameterNaming:
|
||||||
|
active: false
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*|_'
|
||||||
|
MatchingDeclarationName:
|
||||||
|
active: false
|
||||||
|
mustBeFirst: false
|
||||||
|
MemberNameEqualsClassName:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
NoNameShadowing:
|
||||||
|
active: true
|
||||||
|
NonBooleanPropertyPrefixedWithIs:
|
||||||
|
active: false
|
||||||
|
ObjectPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
PackageNaming:
|
||||||
|
active: true
|
||||||
|
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
||||||
|
TopLevelPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Z][_A-Z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
VariableMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumVariableNameLength: 64
|
||||||
|
VariableMinLength:
|
||||||
|
active: false
|
||||||
|
minimumVariableNameLength: 1
|
||||||
|
VariableNaming:
|
||||||
|
active: true
|
||||||
|
variablePattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
privateVariablePattern: '(_)?[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
|
||||||
|
performance:
|
||||||
|
active: true
|
||||||
|
ArrayPrimitive:
|
||||||
|
active: true
|
||||||
|
CouldBeSequence:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ForEachOnRange:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
SpreadOperator:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnnecessaryPartOfBinaryExpression:
|
||||||
|
active: false
|
||||||
|
UnnecessaryTemporaryInstantiation:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
potential-bugs:
|
||||||
|
active: true
|
||||||
|
AvoidReferentialEquality:
|
||||||
|
active: true
|
||||||
|
forbiddenTypePatterns:
|
||||||
|
- 'kotlin.String'
|
||||||
|
CastNullableToNonNullableType:
|
||||||
|
active: false
|
||||||
|
CastToNullableType:
|
||||||
|
active: false
|
||||||
|
Deprecation:
|
||||||
|
active: false
|
||||||
|
DontDowncastCollectionTypes:
|
||||||
|
active: false
|
||||||
|
DoubleMutabilityForCollection:
|
||||||
|
active: true
|
||||||
|
mutableTypes:
|
||||||
|
- 'kotlin.collections.MutableList'
|
||||||
|
- 'kotlin.collections.MutableMap'
|
||||||
|
- 'kotlin.collections.MutableSet'
|
||||||
|
- 'java.util.ArrayList'
|
||||||
|
- 'java.util.LinkedHashSet'
|
||||||
|
- 'java.util.HashSet'
|
||||||
|
- 'java.util.LinkedHashMap'
|
||||||
|
- 'java.util.HashMap'
|
||||||
|
ElseCaseInsteadOfExhaustiveWhen:
|
||||||
|
active: false
|
||||||
|
ignoredSubjectTypes: [ ]
|
||||||
|
EqualsAlwaysReturnsTrueOrFalse:
|
||||||
|
active: true
|
||||||
|
EqualsWithHashCodeExist:
|
||||||
|
active: true
|
||||||
|
ExitOutsideMain:
|
||||||
|
active: false
|
||||||
|
ExplicitGarbageCollectionCall:
|
||||||
|
active: true
|
||||||
|
HasPlatformType:
|
||||||
|
active: true
|
||||||
|
IgnoredReturnValue:
|
||||||
|
active: true
|
||||||
|
restrictToConfig: true
|
||||||
|
returnValueAnnotations:
|
||||||
|
- 'CheckResult'
|
||||||
|
- '*.CheckResult'
|
||||||
|
- 'CheckReturnValue'
|
||||||
|
- '*.CheckReturnValue'
|
||||||
|
ignoreReturnValueAnnotations:
|
||||||
|
- 'CanIgnoreReturnValue'
|
||||||
|
- '*.CanIgnoreReturnValue'
|
||||||
|
returnValueTypes:
|
||||||
|
- 'kotlin.sequences.Sequence'
|
||||||
|
- 'kotlinx.coroutines.flow.*Flow'
|
||||||
|
- 'java.util.stream.*Stream'
|
||||||
|
ignoreFunctionCall: [ ]
|
||||||
|
ImplicitDefaultLocale:
|
||||||
|
active: true
|
||||||
|
ImplicitUnitReturnType:
|
||||||
|
active: false
|
||||||
|
allowExplicitReturnType: true
|
||||||
|
InvalidRange:
|
||||||
|
active: true
|
||||||
|
IteratorHasNextCallsNextMethod:
|
||||||
|
active: true
|
||||||
|
IteratorNotThrowingNoSuchElementException:
|
||||||
|
active: true
|
||||||
|
LateinitUsage:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
ignoreOnClassesPattern: ''
|
||||||
|
MapGetWithNotNullAssertionOperator:
|
||||||
|
active: true
|
||||||
|
MissingPackageDeclaration:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/*.kts' ]
|
||||||
|
NullCheckOnMutableProperty:
|
||||||
|
active: false
|
||||||
|
NullableToStringCall:
|
||||||
|
active: false
|
||||||
|
PropertyUsedBeforeDeclaration:
|
||||||
|
active: false
|
||||||
|
UnconditionalJumpStatementInLoop:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullCheck:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullOperator:
|
||||||
|
active: true
|
||||||
|
UnnecessarySafeCall:
|
||||||
|
active: true
|
||||||
|
UnreachableCatchBlock:
|
||||||
|
active: true
|
||||||
|
UnreachableCode:
|
||||||
|
active: true
|
||||||
|
UnsafeCallOnNullableType:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnsafeCast:
|
||||||
|
active: true
|
||||||
|
UnusedUnaryOperator:
|
||||||
|
active: true
|
||||||
|
UselessPostfixExpression:
|
||||||
|
active: true
|
||||||
|
WrongEqualsTypeParameter:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
style:
|
||||||
|
active: true
|
||||||
|
AlsoCouldBeApply:
|
||||||
|
active: false
|
||||||
|
BracesOnIfStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'never'
|
||||||
|
multiLine: 'always'
|
||||||
|
BracesOnWhenStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'necessary'
|
||||||
|
multiLine: 'consistent'
|
||||||
|
CanBeNonNullable:
|
||||||
|
active: false
|
||||||
|
CascadingCallWrapping:
|
||||||
|
active: false
|
||||||
|
includeElvis: true
|
||||||
|
ClassOrdering:
|
||||||
|
active: false
|
||||||
|
CollapsibleIfStatements:
|
||||||
|
active: false
|
||||||
|
DataClassContainsFunctions:
|
||||||
|
active: false
|
||||||
|
conversionFunctionPrefix:
|
||||||
|
- 'to'
|
||||||
|
allowOperators: false
|
||||||
|
DataClassShouldBeImmutable:
|
||||||
|
active: false
|
||||||
|
DestructuringDeclarationWithTooManyEntries:
|
||||||
|
active: true
|
||||||
|
maxDestructuringEntries: 3
|
||||||
|
DoubleNegativeLambda:
|
||||||
|
active: false
|
||||||
|
negativeFunctions:
|
||||||
|
- reason: 'Use `takeIf` instead.'
|
||||||
|
value: 'takeUnless'
|
||||||
|
- reason: 'Use `all` instead.'
|
||||||
|
value: 'none'
|
||||||
|
negativeFunctionNameParts:
|
||||||
|
- 'not'
|
||||||
|
- 'non'
|
||||||
|
EqualsNullCall:
|
||||||
|
active: true
|
||||||
|
EqualsOnSignatureLine:
|
||||||
|
active: false
|
||||||
|
ExplicitCollectionElementAccessMethod:
|
||||||
|
active: false
|
||||||
|
ExplicitItLambdaParameter:
|
||||||
|
active: true
|
||||||
|
ExpressionBodySyntax:
|
||||||
|
active: false
|
||||||
|
includeLineWrapping: false
|
||||||
|
ForbiddenAnnotation:
|
||||||
|
active: false
|
||||||
|
annotations:
|
||||||
|
- reason: 'it is a java annotation. Use `Suppress` instead.'
|
||||||
|
value: 'java.lang.SuppressWarnings'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
|
||||||
|
value: 'java.lang.Deprecated'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
|
||||||
|
value: 'java.lang.annotation.Documented'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
|
||||||
|
value: 'java.lang.annotation.Target'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
|
||||||
|
value: 'java.lang.annotation.Retention'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
|
||||||
|
value: 'java.lang.annotation.Repeatable'
|
||||||
|
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
|
||||||
|
value: 'java.lang.annotation.Inherited'
|
||||||
|
ForbiddenComment:
|
||||||
|
active: true
|
||||||
|
comments:
|
||||||
|
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
|
||||||
|
value: 'FIXME:'
|
||||||
|
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
|
||||||
|
value: 'STOPSHIP:'
|
||||||
|
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
|
||||||
|
value: 'TODO:'
|
||||||
|
allowedPatterns: ''
|
||||||
|
ForbiddenImport:
|
||||||
|
active: false
|
||||||
|
imports: [ ]
|
||||||
|
forbiddenPatterns: ''
|
||||||
|
ForbiddenMethodCall:
|
||||||
|
active: false
|
||||||
|
methods:
|
||||||
|
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.print'
|
||||||
|
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.println'
|
||||||
|
ForbiddenSuppress:
|
||||||
|
active: false
|
||||||
|
rules: [ ]
|
||||||
|
ForbiddenVoid:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: false
|
||||||
|
ignoreUsageInGenerics: false
|
||||||
|
FunctionOnlyReturningConstant:
|
||||||
|
active: true
|
||||||
|
ignoreOverridableFunction: true
|
||||||
|
ignoreActualFunction: true
|
||||||
|
excludedFunctions: [ ]
|
||||||
|
LoopWithTooManyJumpStatements:
|
||||||
|
active: true
|
||||||
|
maxJumpCount: 1
|
||||||
|
MagicNumber:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
ignoreNumbers:
|
||||||
|
- '-1'
|
||||||
|
- '0'
|
||||||
|
- '1'
|
||||||
|
- '2'
|
||||||
|
ignoreHashCodeFunction: true
|
||||||
|
ignorePropertyDeclaration: false
|
||||||
|
ignoreLocalVariableDeclaration: false
|
||||||
|
ignoreConstantDeclaration: true
|
||||||
|
ignoreCompanionObjectPropertyDeclaration: true
|
||||||
|
ignoreAnnotation: false
|
||||||
|
ignoreNamedArgument: true
|
||||||
|
ignoreEnums: false
|
||||||
|
ignoreRanges: false
|
||||||
|
ignoreExtensionFunctions: true
|
||||||
|
MandatoryBracesLoops:
|
||||||
|
active: false
|
||||||
|
MaxChainedCallsOnSameLine:
|
||||||
|
active: false
|
||||||
|
maxChainedCalls: 5
|
||||||
|
MaxLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
maxLineLength: 120
|
||||||
|
excludePackageStatements: true
|
||||||
|
excludeImportStatements: true
|
||||||
|
excludeCommentStatements: true
|
||||||
|
excludeRawStrings: true
|
||||||
|
MayBeConst:
|
||||||
|
active: true
|
||||||
|
ModifierOrder:
|
||||||
|
active: true
|
||||||
|
MultilineLambdaItParameter:
|
||||||
|
active: false
|
||||||
|
MultilineRawStringIndentation:
|
||||||
|
active: false
|
||||||
|
indentSize: 4
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
NestedClassesVisibility:
|
||||||
|
active: true
|
||||||
|
NewLineAtEndOfFile:
|
||||||
|
active: true
|
||||||
|
NoTabs:
|
||||||
|
active: false
|
||||||
|
NullableBooleanCheck:
|
||||||
|
active: false
|
||||||
|
ObjectLiteralToLambda:
|
||||||
|
active: true
|
||||||
|
OptionalAbstractKeyword:
|
||||||
|
active: true
|
||||||
|
OptionalUnit:
|
||||||
|
active: false
|
||||||
|
OptionalWhenBraces:
|
||||||
|
active: false
|
||||||
|
PreferToOverPairSyntax:
|
||||||
|
active: false
|
||||||
|
ProtectedMemberInFinalClass:
|
||||||
|
active: true
|
||||||
|
RedundantExplicitType:
|
||||||
|
active: false
|
||||||
|
RedundantHigherOrderMapUsage:
|
||||||
|
active: true
|
||||||
|
RedundantVisibilityModifierRule:
|
||||||
|
active: false
|
||||||
|
ReturnCount:
|
||||||
|
active: true
|
||||||
|
max: 3
|
||||||
|
excludedFunctions:
|
||||||
|
- 'equals'
|
||||||
|
excludeLabeled: false
|
||||||
|
excludeReturnFromLambda: true
|
||||||
|
# Guard clauses (early returns for preconditions) are idiomatic Kotlin and should not
|
||||||
|
# count toward the return limit. E.g. `if (x == null) return null` at function top.
|
||||||
|
excludeGuardClauses: true
|
||||||
|
SafeCast:
|
||||||
|
active: true
|
||||||
|
SerialVersionUIDInSerializableClass:
|
||||||
|
active: true
|
||||||
|
SpacingBetweenPackageAndImports:
|
||||||
|
active: false
|
||||||
|
StringShouldBeRawString:
|
||||||
|
active: false
|
||||||
|
maxEscapedCharacterCount: 2
|
||||||
|
ignoredCharacters: [ ]
|
||||||
|
ThrowsCount:
|
||||||
|
active: true
|
||||||
|
max: 2
|
||||||
|
excludeGuardClauses: false
|
||||||
|
TrailingWhitespace:
|
||||||
|
active: false
|
||||||
|
TrimMultilineRawString:
|
||||||
|
active: false
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
UnderscoresInNumericLiterals:
|
||||||
|
active: false
|
||||||
|
acceptableLength: 4
|
||||||
|
allowNonStandardGrouping: false
|
||||||
|
UnnecessaryAbstractClass:
|
||||||
|
active: true
|
||||||
|
UnnecessaryAnnotationUseSiteTarget:
|
||||||
|
active: false
|
||||||
|
UnnecessaryApply:
|
||||||
|
active: true
|
||||||
|
UnnecessaryBackticks:
|
||||||
|
active: false
|
||||||
|
UnnecessaryBracesAroundTrailingLambda:
|
||||||
|
active: false
|
||||||
|
UnnecessaryFilter:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInheritance:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInnerClass:
|
||||||
|
active: false
|
||||||
|
UnnecessaryLet:
|
||||||
|
active: false
|
||||||
|
UnnecessaryParentheses:
|
||||||
|
active: false
|
||||||
|
allowForUnclearPrecedence: false
|
||||||
|
UntilInsteadOfRangeTo:
|
||||||
|
active: false
|
||||||
|
UnusedImports:
|
||||||
|
active: false
|
||||||
|
UnusedParameter:
|
||||||
|
active: true
|
||||||
|
allowedNames: 'ignored|expected'
|
||||||
|
UnusedPrivateClass:
|
||||||
|
active: true
|
||||||
|
UnusedPrivateMember:
|
||||||
|
active: true
|
||||||
|
allowedNames: '[A-Z][a-zA-Z0-9]*Preview'
|
||||||
|
UnusedPrivateProperty:
|
||||||
|
active: true
|
||||||
|
allowedNames: '_|ignored|expected|serialVersionUID'
|
||||||
|
UseAnyOrNoneInsteadOfFind:
|
||||||
|
active: true
|
||||||
|
UseArrayLiteralsInAnnotations:
|
||||||
|
active: true
|
||||||
|
UseCheckNotNull:
|
||||||
|
active: true
|
||||||
|
UseCheckOrError:
|
||||||
|
active: true
|
||||||
|
UseDataClass:
|
||||||
|
active: false
|
||||||
|
allowVars: false
|
||||||
|
UseEmptyCounterpart:
|
||||||
|
active: false
|
||||||
|
UseIfEmptyOrIfBlank:
|
||||||
|
active: false
|
||||||
|
UseIfInsteadOfWhen:
|
||||||
|
active: false
|
||||||
|
ignoreWhenContainingVariableDeclaration: false
|
||||||
|
UseIsNullOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseLet:
|
||||||
|
active: false
|
||||||
|
UseOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseRequire:
|
||||||
|
active: true
|
||||||
|
UseRequireNotNull:
|
||||||
|
active: true
|
||||||
|
UseSumOfInsteadOfFlatMapSize:
|
||||||
|
active: false
|
||||||
|
UselessCallOnNotNull:
|
||||||
|
active: true
|
||||||
|
UtilityClassWithPublicConstructor:
|
||||||
|
active: true
|
||||||
|
VarCouldBeVal:
|
||||||
|
active: true
|
||||||
|
ignoreLateinitVar: false
|
||||||
|
WildcardImport:
|
||||||
|
active: false
|
||||||
|
excludeImports:
|
||||||
|
- 'java.util.*'
|
||||||
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.MapColumn
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
@@ -39,6 +40,18 @@ interface Look4SatDao {
|
|||||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||||
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
||||||
|
|
||||||
|
@Query("SELECT catnum, epoch FROM entries")
|
||||||
|
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
|
||||||
|
|
||||||
|
@Query("SELECT catnum, name FROM entries")
|
||||||
|
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
|
||||||
|
|
||||||
|
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
|
||||||
|
suspend fun renameEntry(catnum: Int, name: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
|
||||||
|
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertEntries(entries: List<SatEntry>)
|
suspend fun insertEntries(entries: List<SatEntry>)
|
||||||
|
|
||||||
@@ -65,4 +78,7 @@ interface Look4SatDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM radios")
|
@Query("DELETE FROM radios")
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
@@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
|
||||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
const val DATABASE_NAME = "Look4SatDBv400"
|
||||||
|
|
||||||
|
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||||
abstract class Look4SatDb : RoomDatabase() {
|
abstract class Look4SatDb : RoomDatabase() {
|
||||||
abstract fun look4SatDao(): Look4SatDao
|
abstract fun look4SatDao(): Look4SatDao
|
||||||
}
|
}
|
||||||
|
|
||||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||||
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
// database.execSQL("DROP TABLE entries")
|
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||||
// }
|
}
|
||||||
//}
|
}
|
||||||
@@ -30,5 +30,6 @@ data class SatEntry(
|
|||||||
val argper: Double,
|
val argper: Double,
|
||||||
val meanan: Double,
|
val meanan: Double,
|
||||||
val catnum: Int,
|
val catnum: Int,
|
||||||
val bstar: Double
|
val bstar: Double,
|
||||||
|
val ndot: Double = 0.0
|
||||||
)
|
)
|
||||||
@@ -32,5 +32,7 @@ data class SatRadio(
|
|||||||
val uplinkHigh: Long?,
|
val uplinkHigh: Long?,
|
||||||
val uplinkMode: String?,
|
val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
val isInverted: Boolean,
|
||||||
val catnum: Int?
|
val catnum: Int?,
|
||||||
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
+4
-3
@@ -330,12 +330,13 @@ object IcomCivProtocol {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||||
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
* Payload layout after stripping command byte: [5 freq bytes]
|
||||||
|
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
|
||||||
*/
|
*/
|
||||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||||
if (payload.size < 6) return null
|
if (payload.size < 5) return null
|
||||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||||
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
|
||||||
return freqHz to mode
|
return freqHz to mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import android.content.Context
|
|||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
|
||||||
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
@@ -149,18 +152,24 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
val remoteSource = provideRemoteSource()
|
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideLocalSource(): ILocalSource {
|
private fun provideLocalSource(): ILocalSource {
|
||||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
|
||||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
|
||||||
return LocalSource(database.look4SatDao())
|
return LocalSource(database.look4SatDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
|
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
|
||||||
|
// Data providers ask clients to identify themselves, so that they can reach out to the
|
||||||
|
// developer instead of silently blocking every user of the app behind a misbehaving request
|
||||||
|
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||||
|
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
|
||||||
|
}.build()
|
||||||
|
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -175,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
val appVersionName = packageInfo.versionName ?: "4.0.4"
|
||||||
|
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||||
|
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+53
-2
@@ -1,5 +1,7 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
@@ -12,6 +14,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@@ -49,6 +52,46 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||||
|
val payload = JSONObject().apply {
|
||||||
|
put("name", submission.name)
|
||||||
|
put("report", submission.report)
|
||||||
|
put("callsign", submission.callsign)
|
||||||
|
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||||
|
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||||
|
}
|
||||||
|
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||||
|
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||||
|
val (code, body) = response
|
||||||
|
val errorMessage = parseSubmitError(body)
|
||||||
|
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||||
|
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||||
|
} else {
|
||||||
|
AmSatReportSubmitResult(
|
||||||
|
success = false,
|
||||||
|
message = errorMessage.ifBlank { "HTTP $code" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitError(json: String): String {
|
||||||
|
return try {
|
||||||
|
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitReportId(json: String): String? {
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||||
|
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse catalog JSON to list of satellite names */
|
/** Parse catalog JSON to list of satellite names */
|
||||||
private fun parseCatalog(json: String): List<String> {
|
private fun parseCatalog(json: String): List<String> {
|
||||||
return try {
|
return try {
|
||||||
@@ -92,11 +135,10 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
val byName = reports.groupBy { it.name }
|
val byName = reports.groupBy { it.name }
|
||||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
||||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
val labels = (0 until 3).map { d ->
|
val labels = (0 until 3).map { d ->
|
||||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
formatDayLabel(utc)
|
||||||
}
|
}
|
||||||
return names.map { name ->
|
return names.map { name ->
|
||||||
val slots = (0 until 36).map { slotIdx ->
|
val slots = (0 until 36).map { slotIdx ->
|
||||||
@@ -121,6 +163,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatDayLabel(calendar: Calendar): String {
|
||||||
|
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||||
|
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||||
|
} else {
|
||||||
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun toSatReport(r: ApiReport): SatReport {
|
private fun toSatReport(r: ApiReport): SatReport {
|
||||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||||
|
|||||||
+90
-17
@@ -43,8 +43,9 @@ class DatabaseRepo(
|
|||||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||||
localSource.insertEntries(entries)
|
insertFresherEntries(entries)
|
||||||
|
// report what the file contained: a valid file holding only stale data is not an error
|
||||||
importedCount = entries.size
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -55,7 +56,7 @@ class DatabaseRepo(
|
|||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertRadios(transceivers)
|
localSource.insertRadios(transceivers, isCustom = true)
|
||||||
importedCount = transceivers.size
|
importedCount = transceivers.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -65,22 +66,38 @@ class DatabaseRepo(
|
|||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val settings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
||||||
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
|
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
|
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
// launch all network requests concurrently
|
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
// parse fetched data concurrently
|
val tleResults = tleJobs.awaitAll()
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val radioResults = radioJobs.awaitAll()
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
}
|
settingsRepo.updateDataSourcesStatus(
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
)
|
||||||
|
// parse fetched data concurrently, merging satellites by freshness instead of source order
|
||||||
|
val parsedPerSource = tleResults.map { (url, result) ->
|
||||||
|
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||||
}
|
}
|
||||||
|
val importedRadios = radioResults.flatMap { (url, result) ->
|
||||||
|
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||||
|
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
localSource.insertEntries(importedEntries)
|
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||||
localSource.insertRadios(importedRadios)
|
// transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||||
|
// Imported ones are kept: no source can refresh them, so nothing would bring them back
|
||||||
|
if (importedRadios.isNotEmpty()) {
|
||||||
|
localSource.deleteManagedRadios()
|
||||||
|
localSource.insertRadios(importedRadios, isCustom = false)
|
||||||
|
}
|
||||||
|
// keep the previous timestamp when every source failed, so the next launch retries
|
||||||
|
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
||||||
|
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
||||||
|
if (hasFetchedData) pruneStaleEntries()
|
||||||
|
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||||
@@ -116,6 +133,62 @@ class DatabaseRepo(
|
|||||||
line.count { it == ',' } >= 4
|
line.count { it == ',' } >= 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the data of every source: orbital elements always come from the set with the newest
|
||||||
|
* epoch, while the name comes from the first source that provides it. Source order is thus a
|
||||||
|
* naming preference only, which also keeps names stable when sources leapfrog each other.
|
||||||
|
*/
|
||||||
|
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
|
||||||
|
val preferredNames = mutableMapOf<Int, String>()
|
||||||
|
val freshestEntries = mutableMapOf<Int, OrbitalData>()
|
||||||
|
sourceEntries.forEach { entries ->
|
||||||
|
entries.forEach { entry ->
|
||||||
|
val name = entry.name.trim()
|
||||||
|
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
|
||||||
|
val current = freshestEntries[entry.catnum]
|
||||||
|
if (current == null || entry.epochDaynum > current.epochDaynum) {
|
||||||
|
freshestEntries[entry.catnum] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return freshestEntries.values.map { entry ->
|
||||||
|
val name = preferredNames[entry.catnum]
|
||||||
|
if (name == null || name == entry.name) entry else entry.copy(name = name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
|
||||||
|
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
|
||||||
|
val storedEpochs = localSource.getEntriesEpochs()
|
||||||
|
val (fresherEntries, staleEntries) = entries.partition { entry ->
|
||||||
|
val storedEpoch = storedEpochs[entry.catnum]
|
||||||
|
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
|
||||||
|
}
|
||||||
|
localSource.insertEntries(fresherEntries)
|
||||||
|
// a reordered source list has to rename satellites right away, even the ones holding
|
||||||
|
// elements that are newer than the ones just parsed
|
||||||
|
if (staleEntries.isNotEmpty()) {
|
||||||
|
val storedNames = localSource.getEntriesNames()
|
||||||
|
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
|
||||||
|
if (renamedEntries.isNotEmpty()) {
|
||||||
|
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
|
||||||
|
* disappeared from every catalog. Manually imported data ages out the same way, and every
|
||||||
|
* source republishes active satellites well within that window.
|
||||||
|
*/
|
||||||
|
private suspend fun pruneStaleEntries() {
|
||||||
|
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
|
||||||
|
val staleIds = localSource.getEntriesEpochs()
|
||||||
|
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
|
||||||
|
.keys.toList()
|
||||||
|
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
settingsRepo.updateDatabaseState(
|
settingsRepo.updateDatabaseState(
|
||||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||||
|
|||||||
+12
-3
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
@@ -67,8 +69,9 @@ class SatelliteRepo(
|
|||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
combine(
|
combine(
|
||||||
settingsRepo.selectedIds,
|
settingsRepo.selectedIds,
|
||||||
settingsRepo.stationPosition
|
settingsRepo.stationPosition,
|
||||||
) { selectedIds, _ -> selectedIds }
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||||
|
) { selectedIds, _, _ -> selectedIds }
|
||||||
.collect { selectedIds ->
|
.collect { selectedIds ->
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
val settings = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
@@ -176,7 +179,13 @@ class SatelliteRepo(
|
|||||||
aosEndMinute: Int,
|
aosEndMinute: Int,
|
||||||
invertAosTimeWindow: Boolean
|
invertAosTimeWindow: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
// Must match the timezone used to display pass times (UTC toggle vs device local)
|
||||||
|
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
||||||
|
TimeZone.getTimeZone("UTC")
|
||||||
|
} else {
|
||||||
|
TimeZone.getDefault()
|
||||||
|
}
|
||||||
|
val offsetMillis = tz.getOffset(aosTime).toLong()
|
||||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||||
val aosMinute = (localMillis / 60_000L).toInt()
|
val aosMinute = (localMillis / 60_000L).toInt()
|
||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
|
|||||||
+19
-2
@@ -114,12 +114,29 @@ class SelectionRepo(
|
|||||||
/**
|
/**
|
||||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||||
* and lowercases the query once up front instead of per-item.
|
* and lowercases the query once up front instead of per-item.
|
||||||
|
*
|
||||||
|
* Fuzzy search: the query is split into space-separated tokens and every
|
||||||
|
* token must appear in the satellite name after both sides are normalized
|
||||||
|
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
||||||
|
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
||||||
|
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
||||||
|
* previously failed whenever the name contained a separator the query lacked.
|
||||||
*/
|
*/
|
||||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||||
if (query.isBlank()) return items
|
if (query.isBlank()) return items
|
||||||
val catnum = query.toIntOrNull()
|
val catnum = query.toIntOrNull()
|
||||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||||
val lowerQuery = query.lowercase()
|
val tokens = query.split(' ')
|
||||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
.map { normalizeForSearch(it) }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
if (tokens.isEmpty()) return items
|
||||||
|
return items.filter { item ->
|
||||||
|
val normalizedName = normalizeForSearch(item.name)
|
||||||
|
tokens.all { normalizedName.contains(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||||
|
private fun normalizeForSearch(text: String): String =
|
||||||
|
text.lowercase().filter { it.isLetterOrDigit() }
|
||||||
}
|
}
|
||||||
+96
-14
@@ -40,11 +40,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String,
|
||||||
|
override val appVersionCode: Long,
|
||||||
) : ISettingsRepo, LocationListenerCompat {
|
) : ISettingsRepo, LocationListenerCompat {
|
||||||
|
|
||||||
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||||
@@ -86,17 +88,33 @@ class SettingsRepo(
|
|||||||
private val keyStationTimestamp = "stationTimestamp"
|
private val keyStationTimestamp = "stationTimestamp"
|
||||||
private val keyUpdateTimestamp = "updateTimestamp"
|
private val keyUpdateTimestamp = "updateTimestamp"
|
||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||||
private val keySatelliteUrls = "satelliteUrls"
|
private val keySatellitesUrls = "satellitesUrls"
|
||||||
private val keyTransceiversUrls = "transceiversUrls"
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
|
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
|
||||||
|
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
|
||||||
|
private val keySettingsVersion = "settingsVersion"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
private val separatorUrl = "\n"
|
private val separatorUrl = "\n"
|
||||||
|
|
||||||
|
init {
|
||||||
|
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
|
||||||
|
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
|
||||||
|
// on the first launch instead of waiting up to 48 hours for the automatic update.
|
||||||
|
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
|
||||||
|
preferences.edit {
|
||||||
|
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
|
||||||
|
.forEach { key -> remove(key) }
|
||||||
|
putInt(keySettingsVersion, appVersionCode.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||||
@@ -373,21 +391,73 @@ class SettingsRepo(
|
|||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
|
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
||||||
|
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
||||||
|
// invariant intact even when a default empty list is used to construct the model.
|
||||||
|
val normalized = settings.copy(
|
||||||
|
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
||||||
|
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
|
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
|
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
|
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
satelliteUrls = preferences.getString(keySatelliteUrls, null)
|
val (satUrls, satEnabled) = parseSources(
|
||||||
?.split(separatorUrl)?.filter { it.isNotBlank() }
|
preferences.getString(keySatellitesUrls, null),
|
||||||
?: Sources.satelliteDataUrls,
|
preferences.getString(keySatellitesUrlsEnabled, null),
|
||||||
transceiversUrls = preferences.getString(keyTransceiversUrls, null)
|
Sources.satelliteDataUrls
|
||||||
?.split(separatorUrl)?.filter { it.isNotBlank() }
|
)
|
||||||
?: Sources.transceiversDataUrls
|
val (txUrls, txEnabled) = parseSources(
|
||||||
)
|
preferences.getString(keyTransceiversUrls, null),
|
||||||
|
preferences.getString(keyTransceiversUrlsEnabled, null),
|
||||||
|
Sources.transceiversDataUrls
|
||||||
|
)
|
||||||
|
return DataSourcesSettings(
|
||||||
|
satelliteUrls = satUrls,
|
||||||
|
transceiversUrls = txUrls,
|
||||||
|
satelliteEnabled = satEnabled,
|
||||||
|
transceiversEnabled = txEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSources(
|
||||||
|
storedUrls: String?,
|
||||||
|
storedEnabled: String?,
|
||||||
|
defaults: List<String>
|
||||||
|
): Pair<List<String>, List<Boolean>> {
|
||||||
|
if (storedUrls == null) return defaults to defaults.map { true }
|
||||||
|
val urls = storedUrls.split(separatorUrl)
|
||||||
|
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
|
||||||
|
val filteredUrls = mutableListOf<String>()
|
||||||
|
val filteredFlags = mutableListOf<Boolean>()
|
||||||
|
urls.forEachIndexed { index, url ->
|
||||||
|
if (url.isNotBlank()) {
|
||||||
|
filteredUrls.add(url)
|
||||||
|
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredUrls to filteredFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||||
|
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||||
|
return flags + List(urls.size - flags.size) { true }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
_dataSourcesStatus.value = status
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -450,4 +520,16 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
private val keyAmSatCallsign = "amSatCallsign"
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String {
|
||||||
|
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) {
|
||||||
|
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
return selectedOrbitalObjects
|
return selectedOrbitalObjects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) =
|
||||||
|
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
|
||||||
|
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||||
|
|
||||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
|
|
||||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||||
@@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||||
|
|
||||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||||
|
|
||||||
|
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||||
@@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
|
||||||
|
|
||||||
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ package com.rtbishop.look4sat.core.data.source
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -42,20 +45,26 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
val response = httpClient.newCall(networkRequest).execute()
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
|
val code = response.code
|
||||||
response.close()
|
response.close()
|
||||||
return@withContext null
|
return@withContext NetworkResult(code, null)
|
||||||
}
|
}
|
||||||
// Return the body stream directly as the caller is responsible for closing it
|
// Return the body stream directly as the caller is responsible for closing it
|
||||||
// That returns the connection to OkHttp's pool
|
// That returns the connection to OkHttp's pool
|
||||||
response.body.byteStream().buffered()
|
val body = response.body
|
||||||
|
if (body == null) {
|
||||||
|
response.close()
|
||||||
|
return@withContext NetworkResult(response.code, null)
|
||||||
|
}
|
||||||
|
NetworkResult(response.code, body.byteStream().buffered())
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,4 +99,21 @@ class RemoteSource(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
response.code to response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource submitAmSatReport exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IcomCivProtocolTest {
|
||||||
|
|
||||||
|
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_ic705ReadFreqReply() {
|
||||||
|
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
|
||||||
|
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
|
||||||
|
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
|
||||||
|
assertNotNull(response)
|
||||||
|
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_withModeByte() {
|
||||||
|
assertEquals(
|
||||||
|
435611000L to "USB",
|
||||||
|
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_tooShort() {
|
||||||
|
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
-5
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -41,6 +42,7 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class DatabaseRepoTest {
|
class DatabaseRepoTest {
|
||||||
@@ -48,6 +50,10 @@ class DatabaseRepoTest {
|
|||||||
private val dispatcher = StandardTestDispatcher()
|
private val dispatcher = StandardTestDispatcher()
|
||||||
private val dataParser = DataParser(dispatcher)
|
private val dataParser = DataParser(dispatcher)
|
||||||
|
|
||||||
|
// fixtures carry a current epoch: stale entries are pruned on every remote update
|
||||||
|
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
|
||||||
|
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||||
val uri = "content://look4sat/import/satellites"
|
val uri = "content://look4sat/import/satellites"
|
||||||
@@ -101,34 +107,198 @@ class DatabaseRepoTest {
|
|||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
|
||||||
|
val primaryUrl = "https://example.com/primary.txt"
|
||||||
|
val secondaryUrl = "https://example.com/secondary.txt"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[primaryUrl] = { validTleStream() }
|
||||||
|
networkStreams[secondaryUrl] = { fresherTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(primaryUrl, secondaryUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
|
||||||
|
assertEquals("ISS (ZARYA)", entry.name)
|
||||||
|
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
|
||||||
|
val freshUri = "content://look4sat/import/fresh"
|
||||||
|
val staleUri = "content://look4sat/import/stale"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[freshUri] = { fresherTleStream() }
|
||||||
|
fileStreams[staleUri] = { validTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
assertEquals(1, repository.updateTLEFromFile(freshUri))
|
||||||
|
assertEquals(1, repository.updateTLEFromFile(staleUri))
|
||||||
|
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
|
||||||
|
val radioUrl = "https://example.com/transmitters.json"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource()
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = emptyList(),
|
||||||
|
transceiversUrls = listOf(radioUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals(2, localSource.insertedRadios.size)
|
||||||
|
|
||||||
|
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
|
||||||
|
val preferredUrl = "https://example.com/preferred.txt"
|
||||||
|
val freshUrl = "https://example.com/fresh.txt"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[freshUrl] = { fresherTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(freshUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals("ISS", localSource.insertedEntries.single().name)
|
||||||
|
|
||||||
|
// the user moves a source with older elements, but a better name, to the top of the list
|
||||||
|
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
|
||||||
|
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(preferredUrl, freshUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
val entry = localSource.insertedEntries.single()
|
||||||
|
assertEquals("ISS (ZARYA)", entry.name)
|
||||||
|
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
|
||||||
|
val failingUrl = "https://example.com/offline.txt"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource()
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(failingUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
// the update timestamp stays put so the data is not presented as fresh, while the recorded
|
||||||
|
// attempt keeps the next app launch from hammering a source that is already failing
|
||||||
|
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
|
||||||
|
val radioUri = "content://imported.json"
|
||||||
|
val radioUrl = "https://example.com/transmitters.json"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[radioUri] = { customRadioStream() }
|
||||||
|
networkStreams[radioUrl] = { singleRadioStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = emptyList(),
|
||||||
|
transceiversUrls = listOf(radioUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
repository.updateTransceiversFromFile(radioUri)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
// the remote snapshot replaces what the sources publish, the imported entry stays put
|
||||||
|
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
private fun validTleStream(): InputStream = """
|
private fun validTleStream(): InputStream = """
|
||||||
ISS (ZARYA)
|
ISS (ZARYA)
|
||||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun fresherTleStream(): InputStream = """
|
||||||
|
ISS
|
||||||
|
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun twoRadiosStream(): InputStream = """
|
||||||
|
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
|
||||||
|
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
|
||||||
|
|
||||||
|
private fun customRadioStream(): InputStream = """
|
||||||
|
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
|
||||||
|
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
|
||||||
|
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
|
||||||
|
|
||||||
private class FakeRemoteSource : IRemoteSource {
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
override suspend fun getNetworkStream(url: String): NetworkResult {
|
||||||
|
val stream = networkStreams[url]?.invoke()
|
||||||
|
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getAmSatCatalog(): String? = null
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
private class FakeLocalSource : ILocalSource {
|
||||||
val insertedEntries = mutableListOf<OrbitalData>()
|
val insertedEntries = mutableListOf<OrbitalData>()
|
||||||
private val insertedRadios = mutableListOf<SatRadio>()
|
val insertedRadios = mutableListOf<SatRadio>()
|
||||||
|
private val customRadios = mutableListOf<String>()
|
||||||
|
|
||||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||||
|
|
||||||
@@ -136,6 +306,21 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesEpochs(): Map<Int, Double> =
|
||||||
|
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames(): Map<Int, String> =
|
||||||
|
insertedEntries.associate { entry -> entry.catnum to entry.name }
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
|
||||||
|
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
|
||||||
|
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
|
||||||
|
insertedEntries.removeAll { entry -> entry.catnum in ids }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||||
insertedEntries += entries
|
insertedEntries += entries
|
||||||
}
|
}
|
||||||
@@ -150,18 +335,27 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
|
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
|
||||||
insertedRadios += radios
|
insertedRadios += radios
|
||||||
|
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
|
||||||
|
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRadios() {
|
override suspend fun deleteRadios() {
|
||||||
insertedRadios.clear()
|
insertedRadios.clear()
|
||||||
|
customRadios.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() {
|
||||||
|
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||||
|
|
||||||
override val appVersionName: String = "test"
|
override val appVersionName: String = "test"
|
||||||
|
override val appVersionCode: Long = 1L
|
||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
@@ -185,6 +379,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
|
|
||||||
|
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
@@ -214,11 +410,19 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
dataSourcesSettings.value = settings
|
dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
dataSourcesStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
|||||||
+79
-3
@@ -84,6 +84,65 @@ class SelectionRepoTest {
|
|||||||
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("ao7")
|
||||||
|
val items = flow.first()
|
||||||
|
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||||
|
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
|
||||||
|
// guarantee is that AO-7 — unreachable before because of dashes/brackets
|
||||||
|
// — is now found.
|
||||||
|
assertEquals(7530, items.first().catnum)
|
||||||
|
assertEquals(listOf(7530, 39444), items.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("fo29")
|
||||||
|
assertEquals(listOf(99999), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("zarya iss")
|
||||||
|
assertEquals(listOf(25544), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partial token query matches substring`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("funcube")
|
||||||
|
assertEquals(listOf(39444), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unmatched query returns nothing`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("zzzznomatch")
|
||||||
|
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun repoWithSearchEntries(): SelectionRepo {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(25544, "ISS (ZARYA)", false),
|
||||||
|
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
|
||||||
|
SatItem(39444, "AO-73 (FUNcube-1)", false),
|
||||||
|
SatItem(43803, "JO-97 (BIRDS-3)", false),
|
||||||
|
SatItem(99999, "FO-29", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeLocalSource(
|
private class FakeLocalSource(
|
||||||
private val entries: List<SatItem>
|
private val entries: List<SatItem>
|
||||||
) : ILocalSource {
|
) : ILocalSource {
|
||||||
@@ -93,6 +152,14 @@ class SelectionRepoTest {
|
|||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||||
|
|
||||||
override suspend fun deleteEntries() = Unit
|
override suspend fun deleteEntries() = Unit
|
||||||
@@ -103,9 +170,11 @@ class SelectionRepoTest {
|
|||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||||
|
|
||||||
override suspend fun deleteRadios() = Unit
|
override suspend fun deleteRadios() = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeSettingsRepo(
|
private class FakeSettingsRepo(
|
||||||
@@ -113,6 +182,7 @@ class SelectionRepoTest {
|
|||||||
) : ISettingsRepo {
|
) : ISettingsRepo {
|
||||||
|
|
||||||
override val appVersionName: String = "test"
|
override val appVersionName: String = "test"
|
||||||
|
override val appVersionCode: Long = 1L
|
||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
@@ -138,6 +208,8 @@ class SelectionRepoTest {
|
|||||||
DataSourcesSettings(emptyList(), emptyList())
|
DataSourcesSettings(emptyList(), emptyList())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
@@ -166,12 +238,16 @@ class SelectionRepoTest {
|
|||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One public AMSAT satellite status report submission. */
|
||||||
|
data class AmSatReportSubmission(
|
||||||
|
val name: String,
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedAtUtcMillis: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of submitting an AMSAT satellite status report. */
|
||||||
|
data class AmSatReportSubmitResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String = "",
|
||||||
|
val reportId: String? = null
|
||||||
|
)
|
||||||
@@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
|
|||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
|
||||||
|
* missing optional field should not cost the whole transceiver. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SatRadio(
|
data class SatRadio(
|
||||||
@SerialName("uuid") val uuid: String,
|
@SerialName("uuid") val uuid: String,
|
||||||
@SerialName("description") val info: String,
|
@SerialName("description") val info: String = "",
|
||||||
@SerialName("alive") val isAlive: Boolean,
|
@SerialName("alive") val isAlive: Boolean = true,
|
||||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
@SerialName("downlink_low") val downlinkLow: Long? = null,
|
||||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
@SerialName("downlink_high") val downlinkHigh: Long? = null,
|
||||||
@SerialName("mode") val downlinkMode: String?,
|
@SerialName("mode") val downlinkMode: String? = null,
|
||||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
@SerialName("uplink_low") val uplinkLow: Long? = null,
|
||||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
@SerialName("uplink_high") val uplinkHigh: Long? = null,
|
||||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
@SerialName("uplink_mode") val uplinkMode: String? = null,
|
||||||
@SerialName("invert") val isInverted: Boolean,
|
@SerialName("invert") val isInverted: Boolean = false,
|
||||||
@SerialName("norad_cat_id") val catnum: Int?
|
@SerialName("norad_cat_id") val catnum: Int? = null
|
||||||
)
|
)
|
||||||
@@ -67,10 +67,21 @@ data class OtherSettings(
|
|||||||
val radarCompassOffsetElev: Float = 0f
|
val radarCompassOffsetElev: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
|
||||||
|
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
|
||||||
|
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
|
||||||
|
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
|
||||||
|
*/
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val satelliteUrls: List<String>,
|
val satelliteUrls: List<String>,
|
||||||
val transceiversUrls: List<String>
|
val transceiversUrls: List<String>,
|
||||||
)
|
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||||
|
val transceiversEnabled: List<Boolean> = emptyList()
|
||||||
|
) {
|
||||||
|
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||||
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
|
}
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
|
|||||||
+6
-2
@@ -424,8 +424,12 @@ object CelestialComputer {
|
|||||||
}
|
}
|
||||||
if (sunrise == 0.0) sunrise = daynum
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
daynum = sunrise
|
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||||
|
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||||
|
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||||
|
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||||
|
daynum = sunrise + 0.001
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
|||||||
+29
-17
@@ -37,30 +37,42 @@ data class OrbitalData(
|
|||||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||||
|
|
||||||
|
/** Absolute age of this element set, used to pick the freshest data among several sources. */
|
||||||
|
val epochDaynum: Double = epochToDaynum(epoch)
|
||||||
|
|
||||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||||
|
|
||||||
/** Check if satellite has likely decayed by the given time. */
|
/** Check if satellite has likely decayed by the given time. */
|
||||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||||
if (ndot == 0.0) return false
|
if (ndot == 0.0) return false
|
||||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||||
val epochDaynum = epochToDaynum(epoch)
|
|
||||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun epochToDaynum(epoch: Double): Double {
|
companion object {
|
||||||
var year = kotlin.math.floor(epoch * 1E-3)
|
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||||
val day = (epoch * 1E-3 - year) * 1000.0
|
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
|
||||||
year = if (year < 57) year + 2000 else year + 1900
|
|
||||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
|
||||||
val jan1Jd = julianDateOfYear(year)
|
|
||||||
return jan1Jd + day - 2444238.5
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun julianDateOfYear(theYear: Double): Double {
|
/**
|
||||||
val aYear = theYear - 1
|
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||||
val b = 2 - a + a / 4
|
* freshness checks must always go through this conversion.
|
||||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
*/
|
||||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
fun epochToDaynum(epoch: Double): Double {
|
||||||
|
var year = kotlin.math.floor(epoch * 1E-3)
|
||||||
|
val day = (epoch * 1E-3 - year) * 1000.0
|
||||||
|
year = if (year < 57) year + 2000 else year + 1900
|
||||||
|
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||||
|
val jan1Jd = julianDateOfYear(year)
|
||||||
|
return jan1Jd + day - 2444238.5
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun julianDateOfYear(theYear: Double): Double {
|
||||||
|
val aYear = theYear - 1
|
||||||
|
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||||
|
val b = 2 - a + a / 4
|
||||||
|
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||||
|
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
@@ -1,9 +1,14 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
/** AMSAT satellite status data source */
|
/** AMSAT satellite status data source */
|
||||||
interface IAmSatRepository {
|
interface IAmSatRepository {
|
||||||
/** Fetch and parse the AMSAT status page; null on failure */
|
/** Fetch and parse the AMSAT status page; null on failure */
|
||||||
suspend fun fetchStatus(): SatStatusPage?
|
suspend fun fetchStatus(): SatStatusPage?
|
||||||
|
|
||||||
|
/** Submit a public AMSAT satellite status report. */
|
||||||
|
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||||
}
|
}
|
||||||
+11
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
interface ISettingsRepo {
|
interface ISettingsRepo {
|
||||||
|
|
||||||
val appVersionName: String
|
val appVersionName: String
|
||||||
|
val appVersionCode: Long
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
@@ -71,6 +72,11 @@ interface ISettingsRepo {
|
|||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||||
|
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
@@ -80,4 +86,9 @@ interface ISettingsRepo {
|
|||||||
fun getSatelliteOffset(catnum: Int): String
|
fun getSatelliteOffset(catnum: Int): String
|
||||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
fun getAmSatCallsign(): String
|
||||||
|
fun setAmSatCallsign(callsign: String)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -26,11 +26,18 @@ interface ILocalSource {
|
|||||||
suspend fun getEntriesTotal(): Int
|
suspend fun getEntriesTotal(): Int
|
||||||
suspend fun getEntriesList(): List<SatItem>
|
suspend fun getEntriesList(): List<SatItem>
|
||||||
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
||||||
|
suspend fun getEntriesEpochs(): Map<Int, Double>
|
||||||
|
suspend fun getEntriesNames(): Map<Int, String>
|
||||||
|
suspend fun renameEntries(names: Map<Int, String>)
|
||||||
|
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
suspend fun insertRadios(radios: List<SatRadio>)
|
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
+10
-1
@@ -19,9 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||||
|
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||||
|
companion object {
|
||||||
|
/** Code used when a request fails before any HTTP response is received. */
|
||||||
|
const val CONNECTION_ERROR = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): NetworkResult
|
||||||
suspend fun getAmSatCatalog(): String?
|
suspend fun getAmSatCatalog(): String?
|
||||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||||
|
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||||
}
|
}
|
||||||
@@ -18,17 +18,19 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
|
// Ordered from the most specific to the most generic source: the first source that knows a
|
||||||
|
// satellite gets to name it, with the full catalog acting as the fallback.
|
||||||
val satelliteDataUrls = listOf(
|
val satelliteDataUrls = listOf(
|
||||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"live.ariss.org/iss.txt",
|
||||||
"db.satnogs.org/api/tle/?format=3le",
|
|
||||||
"amsat.org/tle/current/nasabare.txt",
|
|
||||||
"mmccants.org/tles/classfd.zip",
|
|
||||||
"r4uab.ru/satonline.txt",
|
"r4uab.ru/satonline.txt",
|
||||||
"live.ariss.org/iss.txt"
|
"mmccants.org/tles/classfd.zip",
|
||||||
|
"amsat.org/tle/current/nasabare.txt",
|
||||||
|
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
|
"db.satnogs.org/api/tle/?format=3le"
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = listOf(
|
val transceiversDataUrls = listOf(
|
||||||
"db.satnogs.org/api/transmitters/?format=json&status=active",
|
"r4uab.ru/transmitters.json",
|
||||||
"r4uab.ru/transmitters.json"
|
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
)
|
)
|
||||||
val satelliteModes = listOf(
|
val satelliteModes = listOf(
|
||||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||||
|
|||||||
@@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
coerceInputValues = true
|
coerceInputValues = true
|
||||||
}
|
}
|
||||||
|
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
|
private val celestrakCSVColumns = mapOf(
|
||||||
|
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
|
||||||
|
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
|
||||||
|
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||||
stream.bufferedReader().useLines { lines ->
|
stream.bufferedReader().useLines { lines ->
|
||||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
val iterator = lines.iterator()
|
||||||
|
if (!iterator.hasNext()) return@useLines emptyList()
|
||||||
|
val columns = parseCSVColumns(iterator.next())
|
||||||
|
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,35 +67,63 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
|
||||||
val name = values[0]
|
fun value(column: String) = values[columns.getValue(column)].trim()
|
||||||
val timestamp = values[2]
|
fun optionalValue(column: String) =
|
||||||
val year = timestamp.substring(0, 4)
|
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
val month = timestamp.substring(5, 7).toInt()
|
|
||||||
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
|
||||||
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
|
|
||||||
val day = dayInt.toString().padStart(3, '0')
|
|
||||||
val hour = timestamp.substring(11, 13).toInt() * 3600000
|
|
||||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
|
||||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
|
||||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
|
||||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
|
||||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = name,
|
name = value("OBJECT_NAME"),
|
||||||
epoch = epoch,
|
epoch = parseTimestamp(value("EPOCH")),
|
||||||
meanmo = values[3].toDouble(),
|
meanmo = value("MEAN_MOTION").toDouble(),
|
||||||
eccn = values[4].toDouble(),
|
eccn = value("ECCENTRICITY").toDouble(),
|
||||||
incl = values[5].toDouble(),
|
incl = value("INCLINATION").toDouble(),
|
||||||
raan = values[6].toDouble(),
|
raan = value("RA_OF_ASC_NODE").toDouble(),
|
||||||
argper = values[7].toDouble(),
|
argper = value("ARG_OF_PERICENTER").toDouble(),
|
||||||
meanan = values[8].toDouble(),
|
meanan = value("MEAN_ANOMALY").toDouble(),
|
||||||
catnum = values[11].toInt(),
|
catnum = parseCatnum(value("NORAD_CAT_ID")),
|
||||||
bstar = values[14].toDouble(),
|
bstar = optionalValue("BSTAR"),
|
||||||
ndot = values[15].toDouble()
|
ndot = optionalValue("MEAN_MOTION_DOT")
|
||||||
)
|
)
|
||||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OMM columns are located by name, as providers agree on the names but not on the order.
|
||||||
|
* Falls back to the Celestrak layout when the header is missing or unrecognized.
|
||||||
|
*/
|
||||||
|
private fun parseCSVColumns(header: String): Map<String, Int> {
|
||||||
|
val columns = header.split(",").withIndex().associate { (index, name) ->
|
||||||
|
name.trim().trim('"').uppercase() to index
|
||||||
|
}
|
||||||
|
return if (columns.containsKey("NORAD_CAT_ID")) columns else celestrakCSVColumns
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 8601 timestamp as a TLE style YYDDD.ffffffff epoch. Fractional seconds are optional and
|
||||||
|
* a trailing timezone marker is ignored, as not every provider formats the epoch alike.
|
||||||
|
*/
|
||||||
|
private fun parseTimestamp(timestamp: String): Double {
|
||||||
|
val year = timestamp.substring(0, 4).toInt()
|
||||||
|
val month = timestamp.substring(5, 7).toInt()
|
||||||
|
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
||||||
|
val hours = timestamp.substring(11, 13).toInt()
|
||||||
|
val minutes = timestamp.substring(14, 16).toInt()
|
||||||
|
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble()
|
||||||
|
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0
|
||||||
|
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5:
|
||||||
|
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0.
|
||||||
|
*/
|
||||||
|
private fun parseCatnum(value: String): Int {
|
||||||
|
val catnum = value.trim()
|
||||||
|
if (catnum.first().isDigit()) return catnum.toInt()
|
||||||
|
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar())
|
||||||
|
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" }
|
||||||
|
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
||||||
val line1 = tle[1]
|
val line1 = tle[1]
|
||||||
val line2 = tle[2]
|
val line2 = tle[2]
|
||||||
@@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
raan = line2.substring(17, 25).toDouble(),
|
raan = line2.substring(17, 25).toDouble(),
|
||||||
argper = line2.substring(34, 42).toDouble(),
|
argper = line2.substring(34, 42).toDouble(),
|
||||||
meanan = line2.substring(43, 51).toDouble(),
|
meanan = line2.substring(43, 51).toDouble(),
|
||||||
catnum = line1.substring(2, 7).trim().toInt(),
|
catnum = parseCatnum(line1.substring(2, 7)),
|
||||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||||
ndot = line1.substring(33, 43).trim().toDouble()
|
ndot = line1.substring(33, 43).trim().toDouble()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class DataParserTest {
|
class DataParserTest {
|
||||||
@@ -61,8 +62,49 @@ class DataParserTest {
|
|||||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||||
assert(parsedList.size == 2)
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
|
||||||
|
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.bstar == 0.31985E-4)
|
||||||
|
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val parsedList = dataParser.parseCSVStream(csvStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
|
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
|
||||||
|
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
|
||||||
|
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
|
||||||
|
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
NAVSTAR 43
|
||||||
|
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
|
||||||
|
assert(sat.catnum == 270111)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class CelestialComputerTest {
|
||||||
|
|
||||||
|
private data class RiseSetCase(
|
||||||
|
val name: String,
|
||||||
|
val observer: GeoPos,
|
||||||
|
val startIso: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||||
|
val cases = listOf(
|
||||||
|
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||||
|
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||||
|
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
cases.forEach { testCase ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||||
|
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||||
|
|
||||||
|
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||||
|
|
||||||
|
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||||
|
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||||
|
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||||
|
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||||
|
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||||
|
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||||
|
|
||||||
|
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||||
|
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||||
|
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-3
@@ -52,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -305,6 +305,7 @@ fun InfoDialog(
|
|||||||
title: String,
|
title: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAccept: () -> Unit,
|
onAccept: () -> Unit,
|
||||||
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||||
@@ -323,6 +324,10 @@ fun InfoDialog(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
extraAction?.let {
|
||||||
|
it()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
@@ -425,11 +430,11 @@ private fun DialogShell(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughHeight(): Boolean =
|
fun hasEnoughHeight(): Boolean =
|
||||||
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughWidth(): Boolean =
|
fun hasEnoughWidth(): Boolean =
|
||||||
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||||
|
|||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.dropShadow
|
||||||
|
import androidx.compose.ui.draw.innerShadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
//region DragDrop Region
|
||||||
|
@Composable
|
||||||
|
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val state = remember(lazyListState) {
|
||||||
|
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
while (true) {
|
||||||
|
val diff = state.scrollChannel.receive()
|
||||||
|
lazyListState.scrollBy(diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
class DragDropState internal constructor(
|
||||||
|
private val state: LazyListState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onMove: (Int, Int) -> Unit
|
||||||
|
) {
|
||||||
|
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
internal val scrollChannel = Channel<Float>()
|
||||||
|
|
||||||
|
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||||
|
private var draggingItemInitialOffset by mutableIntStateOf(0)
|
||||||
|
internal val draggingItemOffset: Float
|
||||||
|
get() = draggingItemLayoutInfo?.let { item ->
|
||||||
|
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||||
|
} ?: 0f
|
||||||
|
|
||||||
|
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||||
|
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
|
||||||
|
|
||||||
|
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||||
|
private set
|
||||||
|
internal var previousItemOffset = Animatable(0f)
|
||||||
|
private set
|
||||||
|
|
||||||
|
internal fun onDragStart(offset: Offset) {
|
||||||
|
state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
|
||||||
|
offset.y.toInt() in item.offset..(item.offset + item.size)
|
||||||
|
}?.also {
|
||||||
|
draggingItemIndex = it.index
|
||||||
|
draggingItemInitialOffset = it.offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDragInterrupted() {
|
||||||
|
if (draggingItemIndex != null) {
|
||||||
|
previousIndexOfDraggedItem = draggingItemIndex
|
||||||
|
val startOffset = draggingItemOffset
|
||||||
|
scope.launch {
|
||||||
|
previousItemOffset.snapTo(startOffset)
|
||||||
|
previousItemOffset.animateTo(
|
||||||
|
0f, spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
|
||||||
|
)
|
||||||
|
previousIndexOfDraggedItem = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draggingItemDraggedDelta = 0f
|
||||||
|
draggingItemIndex = null
|
||||||
|
draggingItemInitialOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDrag(offset: Offset) {
|
||||||
|
draggingItemDraggedDelta += offset.y
|
||||||
|
|
||||||
|
val draggingItem = draggingItemLayoutInfo ?: return
|
||||||
|
val startOffset = draggingItem.offset + draggingItemOffset
|
||||||
|
val endOffset = startOffset + draggingItem.size
|
||||||
|
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||||
|
|
||||||
|
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
|
||||||
|
middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index
|
||||||
|
}
|
||||||
|
if (targetItem != null) {
|
||||||
|
if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) {
|
||||||
|
state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset)
|
||||||
|
}
|
||||||
|
onMove.invoke(draggingItem.index, targetItem.index)
|
||||||
|
draggingItemIndex = targetItem.index
|
||||||
|
} else {
|
||||||
|
val overscroll = when {
|
||||||
|
draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||||
|
draggingItemDraggedDelta < 0 -> (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
if (overscroll != 0f) {
|
||||||
|
scrollChannel.trySend(overscroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LazyListItemInfo.offsetEnd: Int
|
||||||
|
get() = this.offset + this.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
|
||||||
|
return pointerInput(dragDropState) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDrag = { change, offset ->
|
||||||
|
change.consume()
|
||||||
|
dragDropState.onDrag(offset = offset)
|
||||||
|
},
|
||||||
|
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||||
|
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||||
|
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyItemScope.DraggableItem(
|
||||||
|
dragDropState: DragDropState,
|
||||||
|
index: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val zIndexMod = Modifier.zIndex(1f)
|
||||||
|
val dragging = index == dragDropState.draggingItemIndex
|
||||||
|
val draggingModifier = when {
|
||||||
|
dragging -> zIndexMod.graphicsLayer { translationY = dragDropState.draggingItemOffset }
|
||||||
|
index == dragDropState.previousIndexOfDraggedItem -> {
|
||||||
|
zIndexMod.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
|
||||||
|
}
|
||||||
|
else -> Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||||
|
}
|
||||||
|
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Swipe Region
|
||||||
|
@Composable
|
||||||
|
fun SwipeableItem(onRemove: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||||
|
val isVisible = remember { mutableStateOf(true) }
|
||||||
|
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
LaunchedEffect(willTrigger) {
|
||||||
|
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||||
|
hapticFeedback.performHapticFeedback(feedbackType)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = isVisible.value, exit = fadeOut(spring())) {
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
enableDismissFromStartToEnd = true,
|
||||||
|
enableDismissFromEndToStart = false,
|
||||||
|
backgroundContent = { DismissBackground(willTrigger) },
|
||||||
|
content = { content() },
|
||||||
|
onDismiss = {
|
||||||
|
isVisible.value = false
|
||||||
|
coroutineScope.launch { dismissState.reset() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(isVisible.value) {
|
||||||
|
if (!isVisible.value) { delay(250.milliseconds).also { onRemove() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DismissBackground(willTrigger: Boolean, bgColor: Color = Color(0xFFFF3C3A)) {
|
||||||
|
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||||
|
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(width = 2.dp, shape = MaterialTheme.shapes.extraLarge, color = bgColor)
|
||||||
|
.dropShadow(shape = MaterialTheme.shapes.extraLarge) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) .2f else 0f
|
||||||
|
}
|
||||||
|
.innerShadow(shape = MaterialTheme.shapes.extraLarge) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) 1f else .2f
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_delete),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale
|
||||||
|
scaleY = iconScale
|
||||||
|
translationX = slide.toPx()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
+311
@@ -0,0 +1,311 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableFloatState
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-press drag-to-reorder for rows inside a `LazyColumn`, with built-in edge auto-scroll and
|
||||||
|
* a settle-back animation when the finger is released.
|
||||||
|
*
|
||||||
|
* Reordering only ever swaps an item with its neighbor *inside the `items` list* passed to
|
||||||
|
* [rememberDragRowState] — so multiple independent groups of rows (e.g. two sections separated
|
||||||
|
* by header items) can share one [DragReorderState]/`LazyColumn` simply by giving each section
|
||||||
|
* its own backing list and calling [rememberDragRowState] with that section's list. Keys must be
|
||||||
|
* globally unique across the whole `LazyColumn` and match exactly what is passed as the
|
||||||
|
* `key = { ... }` lambda of `items`/`itemsIndexed`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```
|
||||||
|
* val dragState = rememberDragReorderState(listState)
|
||||||
|
* itemsIndexed(entries, key = { _, e -> e.id }) { index, entry ->
|
||||||
|
* val row = rememberDragRowState(dragState, entries, entry, key = { it.id }) { from, to ->
|
||||||
|
* entries.add(to, entries.removeAt(from))
|
||||||
|
* }
|
||||||
|
* Row(
|
||||||
|
* modifier = Modifier
|
||||||
|
* .dragLift(row.isLifted, row.translationY)
|
||||||
|
* .animateItem(placementSpec = if (row.isDragging) tween(0) else spring())
|
||||||
|
* ) {
|
||||||
|
* Icon(modifier = Modifier.dragHandle(row), painter = ..., contentDescription = null)
|
||||||
|
* Text(entry.label)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class DragReorderState internal constructor(internal val listState: LazyListState) {
|
||||||
|
internal var draggedKey by mutableStateOf<Any?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberDragReorderState(listState: LazyListState): DragReorderState =
|
||||||
|
remember(listState) { DragReorderState(listState) }
|
||||||
|
|
||||||
|
/** Used for the settle-back animation once the finger is released: quick and snappy. */
|
||||||
|
private val reorderSettleSpec = tween<Float>(durationMillis = 150, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-row drag bookkeeping, obtained via [rememberDragRowState].
|
||||||
|
*
|
||||||
|
* [dragStartOffset] is this row's viewport offset captured once at drag start, [fingerOffset] is
|
||||||
|
* the raw accumulated finger travel (used for edge auto-scroll and swap detection). [translationY]
|
||||||
|
* is derived every frame from those two plus the row's *current* live layout offset, so it
|
||||||
|
* automatically stays correct across neighbor swaps and list auto-scroll without any manual
|
||||||
|
* offset-compensation math.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class DragRowState<T> internal constructor(
|
||||||
|
private val dragState: DragReorderState,
|
||||||
|
private val itemsState: State<List<T>>,
|
||||||
|
private val itemKey: Any,
|
||||||
|
private val key: (T) -> Any,
|
||||||
|
private val onMoveState: State<(Int, Int) -> Unit>,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
internal val fingerOffset: MutableFloatState = mutableFloatStateOf(0f)
|
||||||
|
internal val dragStartOffset: MutableFloatState = mutableFloatStateOf(0f)
|
||||||
|
internal val startCenterY: MutableFloatState = mutableFloatStateOf(0f)
|
||||||
|
internal val settleAnim = Animatable(0f)
|
||||||
|
|
||||||
|
var isSettling: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val isDragging: Boolean get() = dragState.draggedKey == itemKey
|
||||||
|
val isLifted: Boolean get() = isDragging || isSettling
|
||||||
|
|
||||||
|
val translationY: Float
|
||||||
|
get() = when {
|
||||||
|
isSettling -> settleAnim.value
|
||||||
|
isDragging -> currentTranslation()
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far this row must be pushed away from its *current* layout slot so it stays glued to
|
||||||
|
* the finger. Because [dragStartOffset] is fixed at drag start while the row's live layout
|
||||||
|
* offset moves as neighbors swap places or the list auto-scrolls, this difference naturally
|
||||||
|
* absorbs both effects with no extra bookkeeping.
|
||||||
|
*/
|
||||||
|
private fun currentTranslation(): Float {
|
||||||
|
val liveOffset = dragState.listState.layoutInfo.visibleItemsInfo
|
||||||
|
.firstOrNull { it.key == itemKey }?.offset?.toFloat()
|
||||||
|
?: dragStartOffset.floatValue
|
||||||
|
return dragStartOffset.floatValue + fingerOffset.floatValue - liveOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDragStart() {
|
||||||
|
isSettling = false
|
||||||
|
val layout = dragState.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey }
|
||||||
|
dragStartOffset.floatValue = (layout?.offset ?: 0).toFloat()
|
||||||
|
startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
|
||||||
|
fingerOffset.floatValue = 0f
|
||||||
|
dragState.draggedKey = itemKey
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDrag(deltaY: Float) {
|
||||||
|
if (dragState.draggedKey != itemKey) return
|
||||||
|
fingerOffset.floatValue += deltaY
|
||||||
|
reorderLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reorderLive() {
|
||||||
|
val items = itemsState.value
|
||||||
|
val myIndex = items.indexOfFirst { key(it) == itemKey }
|
||||||
|
if (myIndex !in items.indices) return
|
||||||
|
val myCenter = startCenterY.floatValue + fingerOffset.floatValue
|
||||||
|
val visible = dragState.listState.layoutInfo.visibleItemsInfo
|
||||||
|
val onMove = onMoveState.value
|
||||||
|
// Dragging down: swap when the dragged center passes the next row's midpoint.
|
||||||
|
if (myIndex < items.lastIndex) {
|
||||||
|
val nextKey = key(items[myIndex + 1])
|
||||||
|
val next = visible.firstOrNull { it.key == nextKey }
|
||||||
|
if (next != null && myCenter > next.offset + next.size / 2f) {
|
||||||
|
onMove(myIndex, myIndex + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dragging up: swap when the dragged center passes the previous row's midpoint.
|
||||||
|
if (myIndex > 0) {
|
||||||
|
val prevKey = key(items[myIndex - 1])
|
||||||
|
val prev = visible.firstOrNull { it.key == prevKey }
|
||||||
|
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
|
||||||
|
onMove(myIndex, myIndex - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the drag bookkeeping and fly the lifted row back into its slot.
|
||||||
|
// All state resets happen inside the launched block so the settle animation takes over from
|
||||||
|
// the current visual position without a one-frame jump: isSettling is flipped to true
|
||||||
|
// (switching rendering to settleAnim, already snapped to the last offset) before the drag
|
||||||
|
// flags are cleared.
|
||||||
|
internal fun onDragEnd() {
|
||||||
|
val lastOffset = currentTranslation()
|
||||||
|
if (kotlin.math.abs(lastOffset) < 1f) {
|
||||||
|
fingerOffset.floatValue = 0f
|
||||||
|
dragState.draggedKey = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
settleAnim.snapTo(lastOffset)
|
||||||
|
isSettling = true
|
||||||
|
fingerOffset.floatValue = 0f
|
||||||
|
dragState.draggedKey = null
|
||||||
|
settleAnim.animateTo(0f, reorderSettleSpec)
|
||||||
|
isSettling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers a [DragRowState] for one row of a drag-reorderable list.
|
||||||
|
*
|
||||||
|
* [items] must be the exact (optionally section-scoped) list backing the enclosing
|
||||||
|
* `items`/`itemsIndexed` call, and [key] must return the same value used as that call's
|
||||||
|
* `key = { ... }` lambda. Reordering only ever swaps neighbors within [items], so passing a
|
||||||
|
* section-local list is what confines dragging to one section of a multi-section `LazyColumn`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> rememberDragRowState(
|
||||||
|
dragState: DragReorderState,
|
||||||
|
items: List<T>,
|
||||||
|
item: T,
|
||||||
|
key: (T) -> Any,
|
||||||
|
onMove: (from: Int, to: Int) -> Unit
|
||||||
|
): DragRowState<T> {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val itemsState = rememberUpdatedState(items)
|
||||||
|
val onMoveState = rememberUpdatedState(onMove)
|
||||||
|
val itemKey = key(item)
|
||||||
|
val rowState = remember(dragState, itemKey) {
|
||||||
|
DragRowState(dragState, itemsState, itemKey, key, onMoveState, scope)
|
||||||
|
}
|
||||||
|
LaunchedEffect(rowState.isDragging) {
|
||||||
|
if (!rowState.isDragging) return@LaunchedEffect
|
||||||
|
autoScrollWhileDragging(dragState.listState, rowState.startCenterY, rowState.fingerOffset)
|
||||||
|
}
|
||||||
|
return rowState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drag-handle gesture: attach to a small handle icon to start/drive/end reordering of [rowState]'s row. */
|
||||||
|
fun Modifier.dragHandle(rowState: DragRowState<*>): Modifier = pointerInput(rowState) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { rowState.onDragStart() },
|
||||||
|
onDragEnd = { rowState.onDragEnd() },
|
||||||
|
onDragCancel = { rowState.onDragEnd() }
|
||||||
|
) { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
rowState.onDrag(dragAmount.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual treatment for a draggable row: while [isLifted], translates the row by [translationY],
|
||||||
|
* scales it up slightly and raises it above its neighbors with a shadow and a solid background so
|
||||||
|
* it fully covers the row beneath instead of showing a translucent overlap of both rows.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.dragLift(isLifted: Boolean, translationY: Float): Modifier {
|
||||||
|
val shape = MaterialTheme.shapes.small
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isLifted) 1.02f else 1f,
|
||||||
|
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||||
|
label = "dragScale"
|
||||||
|
)
|
||||||
|
return this
|
||||||
|
.graphicsLayer {
|
||||||
|
if (isLifted) {
|
||||||
|
this.translationY = translationY
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
if (isLifted) {
|
||||||
|
Modifier
|
||||||
|
.zIndex(1f)
|
||||||
|
.shadow(8.dp, shape, clip = false)
|
||||||
|
.background(MaterialTheme.colorScheme.surface, shape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
|
||||||
|
* The visual center is tracked independently of the entry's layout slot (which can scroll out of
|
||||||
|
* `LazyListState.layoutInfo.visibleItemsInfo` during a long drag); [startCenterY] is the entry's
|
||||||
|
* viewport center captured at drag start and [fingerOffset] is the raw finger delta. The
|
||||||
|
* resulting scroll is picked up automatically by [DragRowState.translationY] on the next frame,
|
||||||
|
* so no separate scroll-compensation bookkeeping is required here.
|
||||||
|
*/
|
||||||
|
private suspend fun autoScrollWhileDragging(
|
||||||
|
listState: LazyListState,
|
||||||
|
startCenterY: MutableFloatState,
|
||||||
|
fingerOffset: MutableFloatState
|
||||||
|
) {
|
||||||
|
val threshold = 48f
|
||||||
|
val maxSpeed = 24f
|
||||||
|
while (true) {
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val center = startCenterY.floatValue + fingerOffset.floatValue
|
||||||
|
val top = info.viewportStartOffset + threshold
|
||||||
|
val bottom = info.viewportEndOffset - threshold
|
||||||
|
val delta = when {
|
||||||
|
center < top -> -(top - center).coerceAtMost(maxSpeed)
|
||||||
|
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
if (delta != 0f) listState.scrollBy(delta)
|
||||||
|
delay(16L.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -46,7 +46,7 @@ data object Spacing {
|
|||||||
val extraSmall = 6.dp
|
val extraSmall = 6.dp
|
||||||
val small = 8.dp
|
val small = 8.dp
|
||||||
val medium = 12.dp
|
val medium = 12.dp
|
||||||
val large = 16.dp
|
val large = 12.dp
|
||||||
val extraLarge = 24.dp
|
val extraLarge = 24.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as ComponentActivity).window
|
val window = (view.context as ComponentActivity).window
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
insetsController.isAppearanceLightNavigationBars = false
|
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
window.isNavigationBarContrastEnforced = false
|
window.isNavigationBarContrastEnforced = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M240,840L240,760Q240,760 256.5,750Q273,740 292.5,720.5Q312,701 328,670.5Q344,640 344,600Q344,589 342.5,579Q341,569 339,560L240,560L240,480L300,480Q279,447 259.5,410.5Q240,374 240,320Q240,228 304,164Q368,100 460,100Q531,100 586,139Q641,178 665,240L591,271Q576,231 540.5,205.5Q505,180 460,180Q402,180 361,221Q320,262 320,320Q320,368 344,400Q368,432 393,480L560,480L560,560L421,560Q423,569 423.5,579Q424,589 424,600Q424,650 406.5,690Q389,730 364,760L560,760Q600,760 621,739Q642,718 650,685L720,720Q709,775 663.5,807.5Q618,840 560,840L240,840Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
|
||||||
|
</vector>
|
||||||
@@ -118,6 +118,11 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
||||||
|
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
|
||||||
|
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
@@ -156,10 +161,13 @@
|
|||||||
|
|
||||||
<string name="prefs_other_title">Otros</string>
|
<string name="prefs_other_title">Otros</string>
|
||||||
<string name="prefs_other_switch_utc">Hora UTC</string>
|
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
<string name="prefs_other_switch_update">Auto-actualizar</string>
|
||||||
<string name="prefs_other_switch_sweep">Animación radar</string>
|
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Sensores</string>
|
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||||
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||||
|
<string name="prefs_other_switch_light_theme">Tema claro</string>
|
||||||
|
<string name="prefs_other_compass_offset_az">Desfase de brújula (ácimut)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Desfase de brújula (elevación)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
|||||||
@@ -117,6 +117,11 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
|
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Свои источники</string>
|
<string name="prefs_data_sources_title">Свои источники</string>
|
||||||
|
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
|
||||||
|
<string name="prefs_data_sources_sat_title">Данные спутников</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
@@ -156,10 +161,13 @@
|
|||||||
|
|
||||||
<string name="prefs_other_title">Прочее</string>
|
<string name="prefs_other_title">Прочее</string>
|
||||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
<string name="prefs_other_switch_update">Автообновление</string>
|
||||||
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
||||||
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
||||||
|
<string name="prefs_other_switch_light_theme">Светлая тема</string>
|
||||||
|
<string name="prefs_other_compass_offset_az">Смещение компаса (азимут)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Смещение компаса (элевация)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
|||||||
@@ -118,6 +118,11 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
|
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
||||||
|
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්ර ප්රතිස්ථාපනය කරන්න</string>
|
||||||
|
<string name="prefs_data_sources_sat_title">චන්ද්රිකා දත්ත</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">සම්ප්රේෂක දත්ත</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">ඉහළ ප්රමුඛතාව: චන්ද්රිකා නම්</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්රමුඛතාව: සම්ප්රේෂක දත්ත</string>
|
||||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
@@ -156,10 +161,13 @@
|
|||||||
|
|
||||||
<string name="prefs_other_title">වෙනත්</string>
|
<string name="prefs_other_title">වෙනත්</string>
|
||||||
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
||||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
<string name="prefs_other_switch_update">ස්වයං යාවත්කාලීන</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
<string name="prefs_other_switch_sweep">රේඩාර් පරිලෝකනය</string>
|
||||||
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
||||||
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
||||||
|
<string name="prefs_other_switch_light_theme">ආලෝක තේමාව</string>
|
||||||
|
<string name="prefs_other_compass_offset_az">මාලිමා අපගමනය (දිගංශය)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">මාලිමා අපගමනය (උත්තෝලනය)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
|||||||
@@ -149,6 +149,11 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
|
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel kaynaklar</string>
|
<string name="prefs_data_sources_title">Özel kaynaklar</string>
|
||||||
|
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
|
||||||
|
<string name="prefs_data_sources_sat_title">Uydu verileri</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
|
||||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
@@ -188,10 +193,13 @@
|
|||||||
|
|
||||||
<string name="prefs_other_title">Diğer</string>
|
<string name="prefs_other_title">Diğer</string>
|
||||||
<string name="prefs_other_switch_utc">UTC saati</string>
|
<string name="prefs_other_switch_utc">UTC saati</string>
|
||||||
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
<string name="prefs_other_switch_update">Otomatik güncelle</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
||||||
<string name="prefs_other_switch_sensors">Sensör</string>
|
<string name="prefs_other_switch_sensors">Sensör</string>
|
||||||
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
||||||
|
<string name="prefs_other_switch_light_theme">Açık tema</string>
|
||||||
|
<string name="prefs_other_compass_offset_az">Pusula sapması (azimut)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Pusula sapması (yükseklik açısı)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
|
|||||||
@@ -118,6 +118,11 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
|
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Власні джерела</string>
|
<string name="prefs_data_sources_title">Власні джерела</string>
|
||||||
|
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
|
||||||
|
<string name="prefs_data_sources_sat_title">Дані супутників</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
@@ -156,10 +161,13 @@
|
|||||||
|
|
||||||
<string name="prefs_other_title">Інше</string>
|
<string name="prefs_other_title">Інше</string>
|
||||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
<string name="prefs_other_switch_update">Автооновлення</string>
|
||||||
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Сенсори</string>
|
<string name="prefs_other_switch_sensors">Сенсори</string>
|
||||||
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
||||||
|
<string name="prefs_other_switch_light_theme">Світла тема</string>
|
||||||
|
<string name="prefs_other_compass_offset_az">Зміщення компаса (азимут)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Зміщення компаса (піднесення)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
|||||||
@@ -12,6 +12,45 @@
|
|||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
|
<!-- AMSAT Status screen -->
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||||
|
<string name="amsat_refresh">刷新</string>
|
||||||
|
<string name="amsat_retry">重试</string>
|
||||||
|
<string name="amsat_updated">更新于:</string>
|
||||||
|
<string name="amsat_active">活跃</string>
|
||||||
|
<string name="amsat_tlm">遥测/信标</string>
|
||||||
|
<string name="amsat_not_heard">未听到</string>
|
||||||
|
<string name="amsat_conflict">冲突</string>
|
||||||
|
<string name="amsat_name">名称</string>
|
||||||
|
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||||
|
<string name="amsat_no_reports">此时间段无报告</string>
|
||||||
|
<string name="amsat_close">关闭</string>
|
||||||
|
<string name="amsat_status_heard">已听到</string>
|
||||||
|
<string name="amsat_status_tlm_only">仅遥测</string>
|
||||||
|
<string name="amsat_status_not_heard">未听到</string>
|
||||||
|
<string name="amsat_status_crew_active">乘组活跃</string>
|
||||||
|
<string name="amsat_upload">上传</string>
|
||||||
|
<string name="amsat_upload_back">返回</string>
|
||||||
|
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_status">状态</string>
|
||||||
|
<string name="amsat_upload_callsign">呼号</string>
|
||||||
|
<string name="amsat_upload_grid">网格</string>
|
||||||
|
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||||
|
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">确认上传</string>
|
||||||
|
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">未填写</string>
|
||||||
|
<string name="amsat_upload_success">报告已上传</string>
|
||||||
|
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||||
|
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||||
|
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||||
|
<string name="amsat_upload_active">活跃</string>
|
||||||
|
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||||
|
<string name="amsat_upload_not_heard">未听到</string>
|
||||||
|
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">模式:%s</string>
|
<string name="sat_type_hint">模式:%s</string>
|
||||||
<string name="sat_type_title">选择模式</string>
|
<string name="sat_type_title">选择模式</string>
|
||||||
@@ -64,6 +103,24 @@
|
|||||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||||
|
|
||||||
|
<!-- Radar screen (CAT control / SSTV) -->
|
||||||
|
<string name="radar_connect">连接</string>
|
||||||
|
<string name="radar_disconnect">断开连接</string>
|
||||||
|
<string name="radar_off">关</string>
|
||||||
|
<string name="radar_stop">停止</string>
|
||||||
|
<string name="radar_track">跟踪</string>
|
||||||
|
<string name="radar_tx_base">TX 基频:</string>
|
||||||
|
|
||||||
|
<!-- SSTV screen -->
|
||||||
|
<string name="sstv_grant_permission">授予权限</string>
|
||||||
|
<string name="sstv_listening">正在监听…</string>
|
||||||
|
<string name="sstv_mic_permission">需要麦克风权限</string>
|
||||||
|
<string name="sstv_mode_format">模式:%s</string>
|
||||||
|
<string name="sstv_mode_title">SSTV 模式</string>
|
||||||
|
<string name="sstv_no_signal">无信号</string>
|
||||||
|
<string name="sstv_no_transceiver">未选择收发器</string>
|
||||||
|
<string name="sstv_rx_format">RX:%s Hz</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
<string name="map_next">下一个</string>
|
<string name="map_next">下一个</string>
|
||||||
@@ -84,6 +141,12 @@
|
|||||||
<string name="prefs_donate_title">支持</string>
|
<string name="prefs_donate_title">支持</string>
|
||||||
<string name="prefs_privacy_title">隐私政策</string>
|
<string name="prefs_privacy_title">隐私政策</string>
|
||||||
|
|
||||||
|
<string name="settings_paired_devices">已配对的蓝牙设备:</string>
|
||||||
|
<string name="settings_split_mode">分频模式(仅 IC-705)</string>
|
||||||
|
<string name="prefs_lat_prefix">纬度:%s°</string>
|
||||||
|
<string name="prefs_lon_prefix">经度:%s°</string>
|
||||||
|
<string name="prefs_qth_prefix">QTH:%s</string>
|
||||||
|
|
||||||
<string name="prefs_updated_title">更新:%s</string>
|
<string name="prefs_updated_title">更新:%s</string>
|
||||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||||
|
|
||||||
@@ -113,6 +176,11 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">自定义源</string>
|
<string name="prefs_data_sources_title">自定义源</string>
|
||||||
|
<string name="prefs_data_sources_restore">恢复默认源</string>
|
||||||
|
<string name="prefs_data_sources_sat_title">卫星数据</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">收发器数据</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">靠前优先:卫星名称</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
|
||||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
|
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
@@ -151,12 +219,13 @@
|
|||||||
|
|
||||||
<string name="prefs_other_title">其他</string>
|
<string name="prefs_other_title">其他</string>
|
||||||
<string name="prefs_other_switch_utc">UTC 时间</string>
|
<string name="prefs_other_switch_utc">UTC 时间</string>
|
||||||
<string name="prefs_other_switch_update">自动更新数据</string>
|
<string name="prefs_other_switch_update">自动更新</string>
|
||||||
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">雷达扫描</string>
|
||||||
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
<string name="prefs_other_switch_sensors">传感器</string>
|
||||||
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
||||||
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
|
<string name="prefs_other_switch_light_theme">浅色主题</string>
|
||||||
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
|
<string name="prefs_other_compass_offset_az">罗盘偏移量(方位角)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">罗盘偏移量(仰角)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">仰角高亮</string>
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
@@ -173,4 +242,7 @@
|
|||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
|
<!-- Main screen -->
|
||||||
|
<string name="tracking_format">跟踪:%s</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -56,13 +56,12 @@
|
|||||||
\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 AMSAT status tracking page by MCKero6423 (#234)
|
* Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
|
||||||
\n* Tweaked Radar track on reposition by atsunatsu (#235)
|
\n\n* Applied elevation color thresholds to the Radar by atsunatsu (#253)
|
||||||
\n* Tweaked linear transponder calc by atsunatsu (#236)
|
\n\n* Added fuzzy satellite search ignoring separators by atsunatsu (#255)
|
||||||
\n* Added saving per-sat doppler offset by atsunatsu (#237)
|
\n\n* Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
|
||||||
\n* Added custom freq offset setting to network reporting
|
\n\n* Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
|
||||||
\n* Added ability to customize data sources via import
|
\n\n* Fixed day/night map overlay calculation and display issue by rt-bishop
|
||||||
\n* Fixed star chart in README by PingouinFerreux (#240)
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- AMSAT Status screen -->
|
<!-- AMSAT Status screen -->
|
||||||
@@ -79,6 +78,30 @@
|
|||||||
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||||
<string name="amsat_no_reports">No reports for this period</string>
|
<string name="amsat_no_reports">No reports for this period</string>
|
||||||
<string name="amsat_close">Close</string>
|
<string name="amsat_close">Close</string>
|
||||||
|
<string name="amsat_status_heard">Heard</string>
|
||||||
|
<string name="amsat_status_tlm_only">Telemetry Only</string>
|
||||||
|
<string name="amsat_status_not_heard">Not Heard</string>
|
||||||
|
<string name="amsat_status_crew_active">Crew Active</string>
|
||||||
|
<string name="amsat_upload">Upload</string>
|
||||||
|
<string name="amsat_upload_back">Back</string>
|
||||||
|
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||||
|
<string name="amsat_upload_status">Status</string>
|
||||||
|
<string name="amsat_upload_callsign">Callsign</string>
|
||||||
|
<string name="amsat_upload_grid">Grid</string>
|
||||||
|
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||||
|
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">Upload</string>
|
||||||
|
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||||
|
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">Not provided</string>
|
||||||
|
<string name="amsat_upload_success">Report uploaded</string>
|
||||||
|
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||||
|
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||||
|
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
|
||||||
|
<string name="amsat_upload_active">Active</string>
|
||||||
|
<string name="amsat_upload_tlm">TLM/Beacon</string>
|
||||||
|
<string name="amsat_upload_not_heard">Not Heard</string>
|
||||||
|
<string name="amsat_upload_crew_active">Crew Active</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
@@ -109,6 +132,24 @@
|
|||||||
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
||||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
|
||||||
|
<!-- Radar screen (CAT control / SSTV) -->
|
||||||
|
<string name="radar_connect">Connect</string>
|
||||||
|
<string name="radar_disconnect">Disconnect</string>
|
||||||
|
<string name="radar_off">Off</string>
|
||||||
|
<string name="radar_stop">Stop</string>
|
||||||
|
<string name="radar_track">Track</string>
|
||||||
|
<string name="radar_tx_base">TX Base: </string>
|
||||||
|
|
||||||
|
<!-- SSTV screen -->
|
||||||
|
<string name="sstv_grant_permission">Grant permission</string>
|
||||||
|
<string name="sstv_listening">Listening…</string>
|
||||||
|
<string name="sstv_mic_permission">Microphone access needed</string>
|
||||||
|
<string name="sstv_mode_format">Mode: %s</string>
|
||||||
|
<string name="sstv_mode_title">SSTV Mode</string>
|
||||||
|
<string name="sstv_no_signal">No signal</string>
|
||||||
|
<string name="sstv_no_transceiver">No transceiver selected</string>
|
||||||
|
<string name="sstv_rx_format">RX: %s Hz</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
<string name="map_next">Next</string>
|
<string name="map_next">Next</string>
|
||||||
@@ -141,6 +182,12 @@
|
|||||||
<string name="prefs_privacy_title">Privacy Policy</string>
|
<string name="prefs_privacy_title">Privacy Policy</string>
|
||||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||||
|
|
||||||
|
<string name="settings_paired_devices">Paired Bluetooth Devices:</string>
|
||||||
|
<string name="settings_split_mode">Split mode (IC-705 only)</string>
|
||||||
|
<string name="prefs_lat_prefix">Lat: %s°</string>
|
||||||
|
<string name="prefs_lon_prefix">Lon: %s°</string>
|
||||||
|
<string name="prefs_qth_prefix">Qth: %s</string>
|
||||||
|
|
||||||
<string name="prefs_updated_title">Updated: %s</string>
|
<string name="prefs_updated_title">Updated: %s</string>
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||||
|
|
||||||
@@ -170,6 +217,11 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Data sources</string>
|
<string name="prefs_data_sources_title">Data sources</string>
|
||||||
|
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||||
|
<string name="prefs_data_sources_sat_title">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">Transceivers data</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">Top priority affects satellite names</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
@@ -208,12 +260,13 @@
|
|||||||
|
|
||||||
<string name="prefs_other_title">Other</string>
|
<string name="prefs_other_title">Other</string>
|
||||||
<string name="prefs_other_switch_utc">UTC time</string>
|
<string name="prefs_other_switch_utc">UTC time</string>
|
||||||
<string name="prefs_other_switch_update">Auto-update data</string>
|
<string name="prefs_other_switch_update">Auto-update</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors</string>
|
<string name="prefs_other_switch_sensors">Sensors</string>
|
||||||
<string name="prefs_other_switch_night_mode">Night filter</string>
|
<string name="prefs_other_switch_night_mode">Night filter</string>
|
||||||
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
<string name="prefs_other_switch_light_theme">Light theme</string>
|
||||||
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
<string name="prefs_other_compass_offset_az">Compass offset (azimuth)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Compass offset (elevation)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
<string name="prefs_highlight_low">Low < %1$d°</string>
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
@@ -232,4 +285,7 @@
|
|||||||
</string>
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
|
<!-- Main screen -->
|
||||||
|
<string name="tracking_format">Tracking: %s</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
* Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
|
||||||
* Tweaked Radar track on reposition by atsunatsu (#235)
|
* Applied elevation color thresholds to the Radar by atsunatsu (#253)
|
||||||
* Tweaked linear transponder calc by atsunatsu (#236)
|
* Added fuzzy satellite search ignoring separators by atsunatsu (#255)
|
||||||
* Added saving per-sat doppler offset by atsunatsu (#237)
|
* Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
|
||||||
* Added custom freq offset setting to network reporting
|
* Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
|
||||||
* Added ability to customize data sources via import
|
* Fixed day/night map overlay calculation and display issue by rt-bishop
|
||||||
* Fixed star chart in README by PingouinFerreux (#240)
|
|
||||||
@@ -21,6 +21,7 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Overlay
|
import org.osmdroid.views.overlay.Overlay
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
@@ -39,9 +40,11 @@ import kotlin.math.sin
|
|||||||
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
||||||
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
||||||
*
|
*
|
||||||
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
* Performance: we sample one column per stepPx pixels (default 4) and draw
|
||||||
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
||||||
* evaluations per row, which is imperceptible.
|
* evaluations per row, which is imperceptible. draw() is called on every
|
||||||
|
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
|
||||||
|
* single GeoPoint instance.
|
||||||
*/
|
*/
|
||||||
class MapNightOverlay : Overlay() {
|
class MapNightOverlay : Overlay() {
|
||||||
|
|
||||||
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
private val rect = RectF()
|
private val rect = RectF()
|
||||||
|
|
||||||
|
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
|
||||||
|
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||||
if (shadow) return
|
if (shadow) return
|
||||||
|
|
||||||
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
|
|||||||
val h = mapView.height
|
val h = mapView.height
|
||||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
||||||
|
|
||||||
|
// The map is never rotated, so latitude depends only on y and longitude only on x.
|
||||||
|
// Resolve the top/bottom latitudes once instead of once per column.
|
||||||
|
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
|
||||||
|
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
|
||||||
|
val sinLatTop = sin(latTopRad)
|
||||||
|
val cosLatTop = cos(latTopRad)
|
||||||
|
val sinLatBot = sin(latBotRad)
|
||||||
|
val cosLatBot = cos(latBotRad)
|
||||||
|
|
||||||
// We scan column by column. For each column we determine the longitude,
|
// We scan column by column. For each column we determine the longitude,
|
||||||
// then find the latitude range that is in night and shade it.
|
// then find the latitude range that is in night and shade it.
|
||||||
// Since longitude is constant along a vertical strip and the day/night
|
// Since longitude is constant along a vertical strip and the day/night
|
||||||
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
var x = 0
|
var x = 0
|
||||||
while (x < w) {
|
while (x < w) {
|
||||||
// Get the geographic coordinate at the top and bottom of this column.
|
val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
|
||||||
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
|
||||||
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
|
||||||
|
|
||||||
val lonRad = Math.toRadians(geoTop.longitude)
|
val lonRad = Math.toRadians(geoTop.longitude)
|
||||||
val cosLonDiff = cos(lonRad - sunLonRad)
|
val cosLonDiff = cos(lonRad - sunLonRad)
|
||||||
|
|
||||||
// Top pixel geographic lat
|
|
||||||
val latTopRad = Math.toRadians(geoTop.latitude)
|
|
||||||
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
|
||||||
val latBotRad = Math.toRadians(geoBot.latitude)
|
|
||||||
|
|
||||||
// dot(sunVec, pointVec) < 0 → night
|
// dot(sunVec, pointVec) < 0 → night
|
||||||
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
||||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
|
||||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
|
||||||
|
|
||||||
when {
|
when {
|
||||||
dotTop < 0 && dotBot < 0 -> {
|
dotTop < 0 && dotBot < 0 -> {
|
||||||
@@ -109,8 +116,9 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
// Terminator crosses this column — find the crossing pixel by binary search
|
// Terminator crosses this column — find the crossing pixel by binary search
|
||||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
|
val isNightAtTop = dotTop < 0
|
||||||
if (dotTop < 0) {
|
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff, isNightAtTop)
|
||||||
|
if (isNightAtTop) {
|
||||||
// Night at top, day at bottom
|
// Night at top, day at bottom
|
||||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
||||||
canvas.drawRect(rect, nightPaint)
|
canvas.drawRect(rect, nightPaint)
|
||||||
@@ -127,7 +135,11 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
||||||
* [yTop] is in day, [yBot] is in night (or vice versa).
|
*
|
||||||
|
* [isNightAtTop] states which side [yTop] is on. Both polarities occur: the pole nearest
|
||||||
|
* the sub-solar point is lit, so the terminator runs day-over-night while the sub-solar
|
||||||
|
* latitude is positive and night-over-day once it turns negative after the September
|
||||||
|
* equinox. Anchoring lo to the top pixel's side keeps the search valid either way.
|
||||||
*/
|
*/
|
||||||
private fun findCrossingY(
|
private fun findCrossingY(
|
||||||
proj: org.osmdroid.views.Projection,
|
proj: org.osmdroid.views.Projection,
|
||||||
@@ -136,16 +148,18 @@ class MapNightOverlay : Overlay() {
|
|||||||
yBot: Int,
|
yBot: Int,
|
||||||
sinSunLat: Double,
|
sinSunLat: Double,
|
||||||
cosSunLat: Double,
|
cosSunLat: Double,
|
||||||
cosLonDiff: Double
|
cosLonDiff: Double,
|
||||||
|
isNightAtTop: Boolean
|
||||||
): Int {
|
): Int {
|
||||||
var lo = yTop
|
var lo = yTop
|
||||||
var hi = yBot
|
var hi = yBot
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
val mid = (lo + hi) / 2
|
val mid = (lo + hi) / 2
|
||||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
|
||||||
val latRad = Math.toRadians(geo.latitude)
|
val latRad = Math.toRadians(geo.latitude)
|
||||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||||
if (dot < 0) hi = mid else lo = mid
|
// Keep lo on the top pixel's side of the terminator and hi on the opposite side
|
||||||
|
if ((dot < 0) == isNightAtTop) lo = mid else hi = mid
|
||||||
}
|
}
|
||||||
return (lo + hi) / 2
|
return (lo + hi) / 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
@@ -71,6 +70,10 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
|||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import org.osmdroid.events.DelayedMapListener
|
||||||
|
import org.osmdroid.events.MapListener
|
||||||
|
import org.osmdroid.events.ScrollEvent
|
||||||
|
import org.osmdroid.events.ZoomEvent
|
||||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.CustomZoomButtonsController
|
import org.osmdroid.views.CustomZoomButtonsController
|
||||||
@@ -119,6 +122,39 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|||||||
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accent used for the station marker; recolored per theme so it stays visible on a light map. */
|
||||||
|
private var mapAccentColor = "#FFE082".toColorInt()
|
||||||
|
|
||||||
|
/** The dark-map filter (grayscale + invert) is only applied when NOT in light theme; cached to avoid rebuilds. */
|
||||||
|
private val darkTileFilter by lazy { createColorFilter() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recolor the shared overlay paints for the current theme. The light OSM tiles are white, so the
|
||||||
|
* dark-theme amber/grey overlays would vanish; light theme swaps them for dark, high-contrast colors.
|
||||||
|
* Must run before the set*() overlay builders on each frame, as several icons cache the paint color.
|
||||||
|
*/
|
||||||
|
private fun applyMapColors(isLightUi: Boolean) {
|
||||||
|
if (isLightUi) {
|
||||||
|
mapAccentColor = "#715C0C".toColorInt() // dark amber (matches app lightScheme primary)
|
||||||
|
footprintPaint.color = mapAccentColor
|
||||||
|
textPaint.color = "#1E1B13".toColorInt()
|
||||||
|
textPaint.setShadowLayer(3f, 3f, 3f, Color.WHITE)
|
||||||
|
sunIconPaint.colorFilter =
|
||||||
|
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
||||||
|
moonIconPaint.colorFilter =
|
||||||
|
android.graphics.PorterDuffColorFilter("#4C4639".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||||
|
} else {
|
||||||
|
mapAccentColor = "#FFE082".toColorInt()
|
||||||
|
footprintPaint.color = mapAccentColor
|
||||||
|
textPaint.color = mapAccentColor
|
||||||
|
textPaint.setShadowLayer(3f, 3f, 3f, Color.BLACK)
|
||||||
|
sunIconPaint.colorFilter =
|
||||||
|
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
||||||
|
moonIconPaint.colorFilter =
|
||||||
|
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapDestination() {
|
fun MapDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -126,6 +162,21 @@ fun MapDestination() {
|
|||||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val mapView = rememberMapViewWithLifecycle()
|
val mapView = rememberMapViewWithLifecycle()
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
DisposableEffect(lifecycle) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
|
||||||
|
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycle.removeObserver(observer)
|
||||||
|
viewModel.onAction(MapAction.SetVisible(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
MapScreen(uiState, viewModel::onAction, mapView)
|
MapScreen(uiState, viewModel::onAction, mapView)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +210,8 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||||
Box(contentAlignment = Alignment.BottomCenter) {
|
Box(contentAlignment = Alignment.BottomCenter) {
|
||||||
AndroidView({ mapView }) { view ->
|
AndroidView({ mapView }) { view ->
|
||||||
|
applyMapColors(uiState.isLightUi)
|
||||||
|
view.overlayManager.tilesOverlay.setColorFilter(if (uiState.isLightUi) null else darkTileFilter)
|
||||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||||
uiState.footprint?.let { setFootprint(it, view) }
|
uiState.footprint?.let { setFootprint(it, view) }
|
||||||
@@ -270,7 +323,9 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
|||||||
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
|
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
|
||||||
|
setTint(mapAccentColor)
|
||||||
|
}
|
||||||
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,19 +338,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
|||||||
private val markerPool = HashMap<String, Marker>()
|
private val markerPool = HashMap<String, Marker>()
|
||||||
private var lastMapView: MapView? = null
|
private var lastMapView: MapView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this many satellites inside the viewport labels are dropped in favor of a single
|
||||||
|
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
|
||||||
|
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
|
||||||
|
*/
|
||||||
|
private const val LABEL_LIMIT = 128
|
||||||
|
|
||||||
|
/** Degrees of space around the viewport so markers don't pop in at the edges */
|
||||||
|
private const val VIEWPORT_MARGIN = 8.0
|
||||||
|
|
||||||
|
/** Debounce for viewport-driven marker refreshes, in milliseconds */
|
||||||
|
private const val MAP_LISTENER_DELAY = 128L
|
||||||
|
|
||||||
|
/** Shared dot icon used when too many satellites are visible to label them */
|
||||||
|
private var dotIcon: Drawable? = null
|
||||||
|
|
||||||
|
/** Scratch list reused every frame to avoid per-tick allocation */
|
||||||
|
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
|
||||||
|
|
||||||
|
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
|
||||||
|
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
|
||||||
|
private var lastAction: ((OrbitalObject) -> Unit)? = null
|
||||||
|
|
||||||
private fun setPositions(
|
private fun setPositions(
|
||||||
posMap: Map<OrbitalObject, GeoPos>,
|
posMap: Map<OrbitalObject, GeoPos>,
|
||||||
mapView: MapView,
|
mapView: MapView,
|
||||||
action: (OrbitalObject) -> Unit
|
action: (OrbitalObject) -> Unit
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
lastPositions = posMap
|
||||||
|
lastAction = action
|
||||||
// Clear caches when the MapView instance changes (e.g. config change)
|
// Clear caches when the MapView instance changes (e.g. config change)
|
||||||
if (lastMapView !== mapView) {
|
if (lastMapView !== mapView) {
|
||||||
lastMapView = mapView
|
lastMapView = mapView
|
||||||
markerPool.clear()
|
markerPool.clear()
|
||||||
iconCache.evictAll()
|
iconCache.evictAll()
|
||||||
|
dotIcon = null
|
||||||
footprintPolyline = null
|
footprintPolyline = null
|
||||||
footprintPoints = null
|
footprintPoints = null
|
||||||
|
mapView.addMapListener(DelayedMapListener(object : MapListener {
|
||||||
|
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
|
||||||
|
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
|
||||||
|
}, MAP_LISTENER_DELAY))
|
||||||
}
|
}
|
||||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||||
@@ -304,17 +389,48 @@ private fun setPositions(
|
|||||||
}
|
}
|
||||||
folder.items.clear()
|
folder.items.clear()
|
||||||
|
|
||||||
val activeNames = HashSet<String>(posMap.size)
|
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
|
||||||
posMap.forEach { (satellite, geoPos) ->
|
// exactly when marker labels are shown and drawing is most expensive.
|
||||||
|
visibleSats.clear()
|
||||||
|
if (mapView.width > 0 && mapView.height > 0) {
|
||||||
|
val box = mapView.boundingBox
|
||||||
|
val latNorth = box.latNorth + VIEWPORT_MARGIN
|
||||||
|
val latSouth = box.latSouth - VIEWPORT_MARGIN
|
||||||
|
val lonWest = box.lonWest - VIEWPORT_MARGIN
|
||||||
|
val lonEast = box.lonEast + VIEWPORT_MARGIN
|
||||||
|
val wrapsDateLine = box.lonWest > box.lonEast
|
||||||
|
for ((satellite, geoPos) in posMap) {
|
||||||
|
val lat = geoPos.latitude
|
||||||
|
if (lat !in latSouth..latNorth) continue
|
||||||
|
val lon = geoPos.longitude
|
||||||
|
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
|
||||||
|
else lon in lonWest..lonEast
|
||||||
|
if (isLonVisible) visibleSats.add(satellite to geoPos)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (entry in posMap) visibleSats.add(entry.key to entry.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showLabels = visibleSats.size <= LABEL_LIMIT
|
||||||
|
val activeNames = HashSet<String>(visibleSats.size)
|
||||||
|
for ((satellite, geoPos) in visibleSats) {
|
||||||
val name = satellite.data.name
|
val name = satellite.data.name
|
||||||
activeNames.add(name)
|
activeNames.add(name)
|
||||||
val marker = markerPool.getOrPut(name) {
|
val marker = markerPool.getOrPut(name) {
|
||||||
Marker(mapView).apply {
|
Marker(mapView).apply {
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
icon = getCachedTextIcon(name, mapView)
|
// Resolve the satellite via relatedObject so the listener is allocated
|
||||||
|
// once per marker instead of once per satellite per tick
|
||||||
|
setOnMarkerClickListener { clicked, _ ->
|
||||||
|
(clicked.relatedObject as? OrbitalObject)?.let(action)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
marker.relatedObject = satellite
|
||||||
|
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
|
||||||
|
if (marker.icon !== icon) marker.icon = icon
|
||||||
// Update position in-place — reuse existing GeoPoint if available
|
// Update position in-place — reuse existing GeoPoint if available
|
||||||
val pos = marker.position
|
val pos = marker.position
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
@@ -323,22 +439,32 @@ private fun setPositions(
|
|||||||
} else {
|
} else {
|
||||||
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||||
}
|
}
|
||||||
marker.setOnMarkerClickListener { _, _ ->
|
|
||||||
action(satellite)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
folder.add(marker)
|
folder.add(marker)
|
||||||
}
|
}
|
||||||
// Evict markers for satellites no longer tracked
|
// Evict markers that are no longer tracked or no longer visible
|
||||||
val iter = markerPool.keys.iterator()
|
markerPool.keys.retainAll(activeNames)
|
||||||
while (iter.hasNext()) {
|
visibleSats.clear()
|
||||||
if (iter.next() !in activeNames) iter.remove()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println(e)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-applies the last known positions against the new viewport after a pan or zoom */
|
||||||
|
private fun refreshPositions(mapView: MapView): Boolean {
|
||||||
|
val posMap = lastPositions ?: return false
|
||||||
|
val action = lastAction ?: return false
|
||||||
|
setPositions(posMap, mapView, action)
|
||||||
|
mapView.invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
|
||||||
|
val size = 20
|
||||||
|
val bitmap = createBitmap(size, size)
|
||||||
|
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
|
||||||
|
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
|
||||||
|
}
|
||||||
|
|
||||||
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||||
iconCache[name]?.let { return it }
|
iconCache[name]?.let { return it }
|
||||||
val labelRect = Rect()
|
val labelRect = Rect()
|
||||||
@@ -376,9 +502,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
|||||||
private var footprintPolyline: Polyline? = null
|
private var footprintPolyline: Polyline? = null
|
||||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
private var footprintPoints: ArrayList<GeoPoint>? = null
|
||||||
|
|
||||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
val rangeCircle = orbitalPos.getRangeCircle()
|
|
||||||
var pts = footprintPoints
|
var pts = footprintPoints
|
||||||
if (pts == null || pts.size != rangeCircle.size) {
|
if (pts == null || pts.size != rangeCircle.size) {
|
||||||
pts = ArrayList(rangeCircle.size)
|
pts = ArrayList(rangeCircle.size)
|
||||||
@@ -497,7 +622,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
|||||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
// Tile color filter (dark inversion vs light) is applied per-theme in the AndroidView update block.
|
||||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||||
}
|
}
|
||||||
@@ -508,9 +633,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
|||||||
lifecycle.addObserver(lifecycleObserver)
|
lifecycle.addObserver(lifecycleObserver)
|
||||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||||
}
|
}
|
||||||
|
// The overlay caches below are file-level (shared across MapView instances), so they must be
|
||||||
|
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
|
||||||
|
DisposableEffect(mapView) {
|
||||||
|
onDispose {
|
||||||
|
clearMapCaches()
|
||||||
|
mapView.onDetach()
|
||||||
|
}
|
||||||
|
}
|
||||||
return mapView
|
return mapView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearMapCaches() {
|
||||||
|
markerPool.clear()
|
||||||
|
iconCache.evictAll()
|
||||||
|
dotIcon = null
|
||||||
|
footprintPolyline = null
|
||||||
|
footprintPoints = null
|
||||||
|
lastPositions = null
|
||||||
|
lastAction = null
|
||||||
|
lastMapView = null
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||||
LifecycleEventObserver { _, event ->
|
LifecycleEventObserver { _, event ->
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.feature.map
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|
||||||
|
|
||||||
data class MapState(
|
data class MapState(
|
||||||
val mapData: MapData? = null,
|
val mapData: MapData? = null,
|
||||||
@@ -29,7 +28,7 @@ data class MapState(
|
|||||||
val stationPosition: GeoPos? = null,
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>? = null,
|
val track: List<List<GeoPos>>? = null,
|
||||||
val footprint: OrbitalPos? = null,
|
val footprint: List<GeoPos>? = null,
|
||||||
val positions: Map<OrbitalObject, GeoPos>? = null,
|
val positions: Map<OrbitalObject, GeoPos>? = null,
|
||||||
val sunLatDeg: Double = 0.0,
|
val sunLatDeg: Double = 0.0,
|
||||||
val sunLonDeg: Double = 0.0,
|
val sunLonDeg: Double = 0.0,
|
||||||
@@ -42,6 +41,7 @@ sealed interface MapAction {
|
|||||||
data object SelectNext : MapAction
|
data object SelectNext : MapAction
|
||||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||||
|
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MapData(
|
data class MapData(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class MapViewModel(
|
class MapViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -69,12 +72,15 @@ class MapViewModel(
|
|||||||
private var dataUpdateJob: Job? = null
|
private var dataUpdateJob: Job? = null
|
||||||
private var dataUpdateRate = 1000L
|
private var dataUpdateRate = 1000L
|
||||||
private var selectedOrbitalObject: OrbitalObject? = null
|
private var selectedOrbitalObject: OrbitalObject? = null
|
||||||
|
|
||||||
|
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
|
||||||
|
private val isScreenVisible = MutableStateFlow(true)
|
||||||
val uiState: StateFlow<MapState> = _uiState
|
val uiState: StateFlow<MapState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
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, isLightUi = settings.stateOfLightTheme) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
@@ -87,6 +93,7 @@ class MapViewModel(
|
|||||||
MapAction.SelectNext -> scrollSelection(false)
|
MapAction.SelectNext -> scrollSelection(false)
|
||||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||||
|
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +131,11 @@ class MapViewModel(
|
|||||||
selectedOrbitalObject = orbitalObject
|
selectedOrbitalObject = orbitalObject
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataUpdateJob?.cancelAndJoin()
|
dataUpdateJob?.cancelAndJoin()
|
||||||
dataUpdateJob = launch {
|
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
|
||||||
|
// satelliteRepo call internally hops to Default and resumes back on the caller's
|
||||||
|
// dispatcher. On Main that posts one continuation per satellite per tick, which
|
||||||
|
// floods the looper and ANRs for users tracking thousands of objects.
|
||||||
|
dataUpdateJob = launch(Dispatchers.Default) {
|
||||||
val dateNow = Date()
|
val dateNow = Date()
|
||||||
getStationPosition()
|
getStationPosition()
|
||||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||||
@@ -135,9 +146,11 @@ class MapViewModel(
|
|||||||
else -> updateFreq
|
else -> updateFreq
|
||||||
}
|
}
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
// Suspends while the map is off-screen instead of predicting into the void
|
||||||
|
isScreenVisible.first { it }
|
||||||
dateNow.time = System.currentTimeMillis()
|
dateNow.time = System.currentTimeMillis()
|
||||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||||
delay(effectiveRate)
|
delay(effectiveRate.milliseconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +200,8 @@ class MapViewModel(
|
|||||||
|
|
||||||
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
||||||
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
||||||
val footprint = satPos
|
// Range circle is 721 trig-heavy points — keep it on this background dispatcher
|
||||||
|
val footprint = satPos.getRangeCircle()
|
||||||
val mapData = buildMapData(selected, satPos, date)
|
val mapData = buildMapData(selected, satPos, date)
|
||||||
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
||||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
||||||
@@ -294,7 +308,7 @@ class MapViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Number of parallel chunks for satellite position computation */
|
/** Number of parallel chunks for satellite position computation */
|
||||||
private const val PARALLEL_CHUNKS = 4
|
private val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
@@ -115,6 +118,7 @@ private fun RadarScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutPadding()
|
.layoutPadding()
|
||||||
|
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
|
||||||
.keepScreenOn(),
|
.keepScreenOn(),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -78,12 +79,12 @@ internal fun SstvPage(
|
|||||||
) {
|
) {
|
||||||
Text(text = "🎙️", fontSize = 48.sp)
|
Text(text = "🎙️", fontSize = 48.sp)
|
||||||
Text(
|
Text(
|
||||||
text = "Microphone access needed",
|
text = stringResource(R.string.sstv_mic_permission),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Button(onClick = requestMicPermission) {
|
Button(onClick = requestMicPermission) {
|
||||||
Text("Grant permission")
|
Text(stringResource(R.string.sstv_grant_permission))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ internal fun SstvPage(
|
|||||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||||
val dismiss = { showModeDialog.value = false }
|
val dismiss = { showModeDialog.value = false }
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title = "SSTV Mode",
|
title = stringResource(R.string.sstv_mode_title),
|
||||||
onAccept = dismiss,
|
onAccept = dismiss,
|
||||||
onCancel = dismiss
|
onCancel = dismiss
|
||||||
) {
|
) {
|
||||||
@@ -167,7 +168,7 @@ internal fun SstvPage(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
|
text = if (sstv.status == SstvStatus.Recording) stringResource(R.string.sstv_listening) else stringResource(R.string.sstv_no_signal),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
@@ -196,7 +197,7 @@ internal fun SstvPage(
|
|||||||
) {
|
) {
|
||||||
// Doppler-corrected downlink frequency hint
|
// Doppler-corrected downlink frequency hint
|
||||||
OutlinedText(
|
OutlinedText(
|
||||||
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
|
text = dopplerFrequency?.let { stringResource(R.string.sstv_rx_format, it) } ?: stringResource(R.string.sstv_no_transceiver),
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fillColor = MaterialTheme.colorScheme.primary,
|
fillColor = MaterialTheme.colorScheme.primary,
|
||||||
@@ -227,7 +228,7 @@ internal fun SstvPage(
|
|||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = "Mode: ${sstv.selectedMode}",
|
text = stringResource(R.string.sstv_mode_format, sstv.selectedMode),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ private fun ExpandedRadioControl(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "TX Base: ",
|
text = stringResource(R.string.radar_tx_base),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -460,7 +460,7 @@ private fun ExpandedRadioControl(
|
|||||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
text = "Off",
|
text = stringResource(R.string.radar_off),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@@ -492,19 +492,19 @@ private fun ExpandedRadioControl(
|
|||||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.ConnectRadios) },
|
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||||
text = "Connect",
|
text = stringResource(R.string.radar_connect),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.DisconnectRadios) },
|
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||||
text = "Disconnect",
|
text = stringResource(R.string.radar_disconnect),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.ToggleTracking) },
|
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||||
text = if (radioControl.isTracking) "Stop" else "Track",
|
text = if (radioControl.isTracking) stringResource(R.string.radar_stop) else stringResource(R.string.radar_track),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -726,7 +726,7 @@ private fun DopplerFrequencyCalculator(
|
|||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
if (offsetKHz.isEmpty()) {
|
if (offsetKHz.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Offset (kHz)",
|
text = stringResource(R.string.radar_doppler_offset_hint),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
+199
-55
@@ -17,7 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -29,9 +30,12 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -39,17 +43,17 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
import androidx.compose.runtime.toMutableStateList
|
import androidx.compose.runtime.toMutableStateList
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
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
|
||||||
@@ -60,12 +64,19 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.DragReorderState
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||||
|
import com.rtbishop.look4sat.core.presentation.dragHandle
|
||||||
|
import com.rtbishop.look4sat.core.presentation.dragLift
|
||||||
|
import com.rtbishop.look4sat.core.presentation.rememberDragReorderState
|
||||||
|
import com.rtbishop.look4sat.core.presentation.rememberDragRowState
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -152,44 +163,94 @@ private fun TransceiversDialogPreview() {
|
|||||||
transceiversUrls = listOf(
|
transceiversUrls = listOf(
|
||||||
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
),
|
),
|
||||||
|
satelliteEnabled = listOf(true, false),
|
||||||
|
transceiversEnabled = listOf(true),
|
||||||
|
statusCodes = mapOf(
|
||||||
|
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
|
||||||
|
"amsat.org/tle/current/nasabare.txt" to 404
|
||||||
|
),
|
||||||
onImportTle = {},
|
onImportTle = {},
|
||||||
onImportTransceivers = {},
|
onImportTransceivers = {},
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
onSave = { _, _ -> }
|
onSave = { _, _, _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for one data-source row: url, enabled flag and stable id all live
|
||||||
|
* together so a reorder, a toggle or an edit is a single list mutation instead of several parallel
|
||||||
|
* lists/maps having to stay in sync (which used to fan out into extra recompositions).
|
||||||
|
*/
|
||||||
|
private data class SourceEntry(val id: Long, val url: String, val enabled: Boolean = true)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DataSourcesDialog(
|
fun DataSourcesDialog(
|
||||||
satelliteUrls: List<String>,
|
satelliteUrls: List<String>,
|
||||||
transceiversUrls: List<String>,
|
transceiversUrls: List<String>,
|
||||||
|
satelliteEnabled: List<Boolean>,
|
||||||
|
transceiversEnabled: List<Boolean>,
|
||||||
|
statusCodes: Map<String, Int>,
|
||||||
onImportTle: () -> Unit,
|
onImportTle: () -> Unit,
|
||||||
onImportTransceivers: () -> Unit,
|
onImportTransceivers: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (List<String>, List<String>) -> Unit
|
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
|
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
|
||||||
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
||||||
val satUrls = remember {
|
val satEntries = remember {
|
||||||
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
|
satelliteUrls.mapIndexed { i, url ->
|
||||||
|
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
|
||||||
|
}.toMutableStateList()
|
||||||
}
|
}
|
||||||
val txUrls = remember {
|
val txEntries = remember {
|
||||||
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
transceiversUrls.mapIndexed { i, url ->
|
||||||
|
SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true })
|
||||||
|
}.toMutableStateList()
|
||||||
}
|
}
|
||||||
val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() }
|
val listState = rememberLazyListState()
|
||||||
|
val dragState = rememberDragReorderState(listState)
|
||||||
|
val onRestoreSatDefaults = {
|
||||||
|
satEntries.clear()
|
||||||
|
satEntries.addAll(Sources.satelliteDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
val onRestoreTxDefaults = {
|
||||||
|
txEntries.clear()
|
||||||
|
txEntries.addAll(Sources.transceiversDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
val onAccept = {
|
||||||
|
val satFiltered = satEntries.filter { it.url.isNotBlank() }
|
||||||
|
val txFiltered = txEntries.filter { it.url.isNotBlank() }
|
||||||
|
try {
|
||||||
|
onSave(
|
||||||
|
satFiltered.map { it.url },
|
||||||
|
txFiltered.map { it.url },
|
||||||
|
satFiltered.map { it.enabled },
|
||||||
|
txFiltered.map { it.enabled }
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val satTitle = stringResource(R.string.prefs_data_sources_sat_title)
|
||||||
|
val satHint = stringResource(R.string.prefs_data_sources_sat_hint)
|
||||||
|
val transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title)
|
||||||
|
val transceiversHint = stringResource(R.string.prefs_data_sources_transceivers_hint)
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
title = stringResource(id = R.string.prefs_data_sources_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept,
|
onAccept = onAccept,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(0.84f)
|
.fillMaxHeight(0.84f)
|
||||||
.padding(horizontal = padding),
|
.padding(horizontal = padding),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
contentPadding = PaddingValues(vertical = 6.dp)
|
contentPadding = PaddingValues(vertical = 0.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
@@ -210,21 +271,31 @@ fun DataSourcesDialog(
|
|||||||
}
|
}
|
||||||
sourceSection(
|
sourceSection(
|
||||||
sectionKey = "sat",
|
sectionKey = "sat",
|
||||||
label = "Satellites data",
|
label = satTitle,
|
||||||
urls = satUrls,
|
hint = satHint,
|
||||||
onAdd = { satUrls.add(nextId.longValue++ to "") },
|
entries = satEntries,
|
||||||
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
|
dragState = dragState,
|
||||||
onRemove = { i -> satUrls.removeAt(i) },
|
statusCodes = statusCodes,
|
||||||
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
|
onAdd = { satEntries.add(SourceEntry(nextId.longValue++, "")) },
|
||||||
|
onRestore = onRestoreSatDefaults,
|
||||||
|
onMove = { from, to -> satEntries.add(to, satEntries.removeAt(from)) },
|
||||||
|
onRemove = { i -> satEntries.removeAt(i) },
|
||||||
|
onToggle = { i -> satEntries[i] = satEntries[i].copy(enabled = !satEntries[i].enabled) },
|
||||||
|
onUrlChange = { i, v -> satEntries[i] = satEntries[i].copy(url = v) }
|
||||||
)
|
)
|
||||||
sourceSection(
|
sourceSection(
|
||||||
sectionKey = "tx",
|
sectionKey = "tx",
|
||||||
label = "Transceivers data",
|
label = transceiversTitle,
|
||||||
urls = txUrls,
|
hint = transceiversHint,
|
||||||
onAdd = { txUrls.add(nextId.longValue++ to "") },
|
entries = txEntries,
|
||||||
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
|
dragState = dragState,
|
||||||
onRemove = { i -> txUrls.removeAt(i) },
|
statusCodes = statusCodes,
|
||||||
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
|
onAdd = { txEntries.add(SourceEntry(nextId.longValue++, "")) },
|
||||||
|
onRestore = onRestoreTxDefaults,
|
||||||
|
onMove = { from, to -> txEntries.add(to, txEntries.removeAt(from)) },
|
||||||
|
onRemove = { i -> txEntries.removeAt(i) },
|
||||||
|
onToggle = { i -> txEntries[i] = txEntries[i].copy(enabled = !txEntries[i].enabled) },
|
||||||
|
onUrlChange = { i, v -> txEntries[i] = txEntries[i].copy(url = v) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,10 +304,15 @@ fun DataSourcesDialog(
|
|||||||
private fun LazyListScope.sourceSection(
|
private fun LazyListScope.sourceSection(
|
||||||
sectionKey: String,
|
sectionKey: String,
|
||||||
label: String,
|
label: String,
|
||||||
urls: List<Pair<Long, String>>,
|
hint: String,
|
||||||
|
entries: SnapshotStateList<SourceEntry>,
|
||||||
|
dragState: DragReorderState,
|
||||||
|
statusCodes: Map<String, Int>,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onMoveUp: (Int) -> Unit,
|
onRestore: () -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
onRemove: (Int) -> Unit,
|
onRemove: (Int) -> Unit,
|
||||||
|
onToggle: (Int) -> Unit,
|
||||||
onUrlChange: (Int, String) -> Unit
|
onUrlChange: (Int, String) -> Unit
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
@@ -248,39 +324,107 @@ private fun LazyListScope.sourceSection(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
IconCard(
|
||||||
|
action = onRestore,
|
||||||
|
resId = R.drawable.ic_restore,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
IconCard(
|
||||||
|
action = onAdd,
|
||||||
|
resId = R.drawable.ic_add,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
Text(
|
||||||
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
|
text = hint,
|
||||||
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
OutlinedTextField(
|
|
||||||
value = url,
|
|
||||||
onValueChange = { onUrlChange(index, it) },
|
|
||||||
label = { Text("Source URL") },
|
|
||||||
leadingIcon = {
|
|
||||||
IconButton(onClick = { onMoveUp(index) }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_arrow),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
|
|
||||||
modifier = Modifier.rotate(270f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
IconButton(onClick = { onRemove(index) }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_delete),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring())
|
.padding(top = 2.dp, bottom = 8.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
itemsIndexed(entries, key = { _, entry -> "$sectionKey-${entry.id}" }) { index, entry ->
|
||||||
|
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val rowState = rememberDragRowState(
|
||||||
|
dragState = dragState,
|
||||||
|
items = entries,
|
||||||
|
item = entry,
|
||||||
|
key = { "$sectionKey-${it.id}" },
|
||||||
|
onMove = onMove
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.dragLift(isLifted = rowState.isLifted, translationY = rowState.translationY)
|
||||||
|
.animateItem(
|
||||||
|
fadeInSpec = tween(durationMillis = 200),
|
||||||
|
// The dragged row repositions instantly, while its neighbors smoothly
|
||||||
|
// slide out of the way (the "squeeze" effect).
|
||||||
|
placementSpec = if (rowState.isDragging) {
|
||||||
|
tween(durationMillis = 0)
|
||||||
|
} else {
|
||||||
|
tween(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
},
|
||||||
|
fadeOutSpec = tween(durationMillis = 200)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_drag_handle),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = enabledTint,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = textFieldLabelOffset)
|
||||||
|
.dragHandle(rowState)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = entry.url,
|
||||||
|
onValueChange = { onUrlChange(index, it) },
|
||||||
|
label = {
|
||||||
|
Row {
|
||||||
|
Text("Source URL")
|
||||||
|
statusCodes[entry.url]?.let { code ->
|
||||||
|
Text(" - ${statusLabel(code)}", color = statusColor(code), fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { onRemove(index) }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_delete),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
enabled = entry.enabled,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = entry.enabled,
|
||||||
|
onCheckedChange = { onToggle(index) },
|
||||||
|
modifier = Modifier.padding(top = textFieldLabelOffset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compensates for the visual weight of the [OutlinedTextField]'s floating label: the label
|
||||||
|
* pushes the perceived center of the field's content down a bit, so the drag handle and
|
||||||
|
* checkbox are nudged down by the same amount to stay visually centered on the input line.
|
||||||
|
*/
|
||||||
|
private val textFieldLabelOffset = 8.dp
|
||||||
|
|
||||||
|
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun statusColor(code: Int): Color = when (code) {
|
||||||
|
NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
|
||||||
|
in 200..299 -> Color(0xFF66BB6A)
|
||||||
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@@ -648,7 +792,7 @@ fun RadioControlDialog(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Split mode (IC-705 only)",
|
text = stringResource(R.string.settings_split_mode),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = splitModeLabelColor
|
color = splitModeLabelColor
|
||||||
)
|
)
|
||||||
@@ -688,7 +832,7 @@ fun RadioControlDialog(
|
|||||||
|
|
||||||
if (selectingFor.value.isNotBlank()) {
|
if (selectingFor.value.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Paired Bluetooth Devices:",
|
text = stringResource(R.string.settings_paired_devices),
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
|||||||
+54
-33
@@ -120,11 +120,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||||
|
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||||
|
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||||
|
statusCodes = uiState.dataSourcesStatus,
|
||||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||||
onDismiss = { dialogs.dataSources = false },
|
onDismiss = { dialogs.dataSources = false },
|
||||||
onSave = { satUrls, txUrls ->
|
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||||
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
|
val newSettings = DataSourcesSettings(
|
||||||
|
satelliteUrls = satUrls,
|
||||||
|
transceiversUrls = txUrls,
|
||||||
|
satelliteEnabled = satEnabled,
|
||||||
|
transceiversEnabled = txEnabled
|
||||||
|
)
|
||||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
onAction(SettingsAction.UpdateFromWeb)
|
onAction(SettingsAction.UpdateFromWeb)
|
||||||
}
|
}
|
||||||
@@ -180,7 +188,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
|
|
||||||
// URLs for top bar
|
// URLs for top bar
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val appUrl = stringResource(R.string.prefs_app_url)
|
// val appUrl = stringResource(R.string.prefs_app_url)
|
||||||
val donateUrl = stringResource(R.string.prefs_donate_url)
|
val donateUrl = stringResource(R.string.prefs_donate_url)
|
||||||
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
|
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
|
||||||
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
|
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
|
||||||
@@ -201,7 +209,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
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)) {
|
||||||
@@ -223,7 +231,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { dialogs.whatsNew = true },
|
onClick = { dialogs.whatsNew = true },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
@@ -334,9 +342,9 @@ private fun LocationCard(
|
|||||||
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude))
|
||||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude))
|
||||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
@@ -487,17 +495,31 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
|||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
// Display preferences: UTC clock + night filter
|
// Data preferences: auto-update + UTC clock
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||||
|
onAction(SettingsAction.ToggleUpdate(it))
|
||||||
|
}
|
||||||
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
||||||
onAction(SettingsAction.ToggleUtc(it))
|
onAction(SettingsAction.ToggleUtc(it))
|
||||||
}
|
}
|
||||||
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Appearance: light theme + night filter
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_light_theme, settings.stateOfLightTheme) {
|
||||||
|
onAction(SettingsAction.ToggleLightTheme(it))
|
||||||
|
}
|
||||||
|
SwitchTile(
|
||||||
|
R.string.prefs_other_switch_night_mode,
|
||||||
|
settings.stateOfNightMode,
|
||||||
|
enabled = !settings.stateOfLightTheme
|
||||||
|
) {
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
onAction(SettingsAction.ToggleNightMode(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
// Radar behavior: sweep animation + sensor control
|
// Radar behavior: sweep animation + sensors, followed by the compass offset sliders
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||||
onAction(SettingsAction.ToggleSweep(it))
|
onAction(SettingsAction.ToggleSweep(it))
|
||||||
@@ -507,14 +529,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
// Data management (full width)
|
|
||||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
|
||||||
onAction(SettingsAction.ToggleUpdate(it))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
// Compass calibration sliders at the bottom
|
// Compass calibration sliders at the bottom
|
||||||
CompassOffsetRow(
|
CompassOffsetRow(
|
||||||
labelResId = R.string.prefs_other_compass_offset,
|
labelResId = R.string.prefs_other_compass_offset_az,
|
||||||
value = settings.radarCompassOffset,
|
value = settings.radarCompassOffset,
|
||||||
range = -180f..180f
|
range = -180f..180f
|
||||||
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
||||||
@@ -541,7 +558,14 @@ private fun CompassOffsetRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(id = labelResId))
|
Text(
|
||||||
|
text = stringResource(id = labelResId),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
.infiniteMarquee(),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
Text(text = "${value.toInt()}°")
|
Text(text = "${value.toInt()}°")
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
@@ -553,19 +577,12 @@ private fun CompassOffsetRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
private fun RowScope.SwitchTile(
|
||||||
Row(
|
labelResId: Int,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
checked: Boolean,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
enabled: Boolean = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
onCheckedChange: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(id = labelResId))
|
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -573,9 +590,13 @@ private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChan
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = labelResId),
|
text = stringResource(id = labelResId),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
.infiniteMarquee(),
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ data class SettingsState(
|
|||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
val radioControlSettings: RadioControlSettings,
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings,
|
val dataSourcesSettings: DataSourcesSettings,
|
||||||
|
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||||
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -49,6 +49,7 @@ class SettingsViewModel(
|
|||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||||
|
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||||
pairedBluetoothDevices = container.providePairedBluetoothDevices()
|
pairedBluetoothDevices = container.providePairedBluetoothDevices()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -92,6 +93,11 @@ class SettingsViewModel(
|
|||||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.dataSourcesStatus.collect { status ->
|
||||||
|
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.radioControlSettings.collect { settings ->
|
settingsRepo.radioControlSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||||
@@ -121,7 +127,10 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
|
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
|
||||||
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
|
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
|
||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings {
|
||||||
|
// Night filter (red-only overlay) is incompatible with the light theme, so turn it off.
|
||||||
|
it.copy(stateOfLightTheme = action.value, stateOfNightMode = if (action.value) false else it.stateOfNightMode)
|
||||||
|
}
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
||||||
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
||||||
@@ -200,7 +209,6 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import com.android.build.api.dsl.LibraryExtension
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.featurePlugin)
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rtbishop.look4sat.feature.status"
|
namespace = "com.rtbishop.look4sat.feature.status"
|
||||||
compileOptions {
|
|
||||||
encoding = "UTF-8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":core:domain"))
|
|
||||||
implementation(project(":core:presentation"))
|
|
||||||
}
|
}
|
||||||
+305
-10
@@ -1,5 +1,6 @@
|
|||||||
package com.rtbishop.look4sat.feature.status
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -20,9 +21,14 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -51,14 +57,32 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
||||||
private val TILE_WIDTH: Dp = 64.dp
|
private val TILE_WIDTH: Dp = 64.dp
|
||||||
|
|
||||||
|
private data class UploadOption(
|
||||||
|
val apiValue: String,
|
||||||
|
val labelResId: Int,
|
||||||
|
val color: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
private val UPLOAD_OPTIONS = listOf(
|
||||||
|
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
|
||||||
|
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
|
||||||
|
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
|
||||||
|
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map AMSAT status text to Material3 colorScheme colors.
|
* Map AMSAT status text to Material3 colorScheme colors.
|
||||||
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
||||||
@@ -77,17 +101,52 @@ private fun statusColorOf(statusText: String): Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Localize AMSAT API status text for the current locale; falls back to the raw text. */
|
||||||
|
@Composable
|
||||||
|
private fun statusTextOf(statusText: String): String {
|
||||||
|
if (Locale.getDefault().language != Locale.CHINESE.language) return statusText
|
||||||
|
return when (statusText.lowercase()) {
|
||||||
|
"heard" -> stringResource(R.string.amsat_status_heard)
|
||||||
|
"telemetry only" -> stringResource(R.string.amsat_status_tlm_only)
|
||||||
|
"not heard" -> stringResource(R.string.amsat_status_not_heard)
|
||||||
|
"crew active" -> stringResource(R.string.amsat_status_crew_active)
|
||||||
|
else -> statusText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SatStatusDestination() {
|
fun SatStatusDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SatStatusScreen(uiState) { viewModel.refresh() }
|
SatStatusScreen(
|
||||||
|
uiState = uiState,
|
||||||
|
refresh = viewModel::refresh,
|
||||||
|
onToggleUpload = viewModel::toggleUploadPanel,
|
||||||
|
onUploadReportChange = viewModel::setUploadReport,
|
||||||
|
onUploadCallsignChange = viewModel::setUploadCallsign,
|
||||||
|
onUploadGridChange = viewModel::setUploadGrid,
|
||||||
|
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
|
||||||
|
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
|
||||||
|
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
|
||||||
|
onDismissUpload = viewModel::collapseUploadPanel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
private fun SatStatusScreen(
|
||||||
|
uiState: SatStatusUiState,
|
||||||
|
refresh: () -> Unit,
|
||||||
|
onToggleUpload: () -> Unit,
|
||||||
|
onUploadReportChange: (String) -> Unit,
|
||||||
|
onUploadCallsignChange: (String) -> Unit,
|
||||||
|
onUploadGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitUpload: () -> Unit,
|
||||||
|
onConfirmSubmitUpload: (String) -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
|
onDismissUpload: () -> Unit
|
||||||
|
) {
|
||||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||||
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
||||||
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
||||||
@@ -122,7 +181,23 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedDay?.let { (status, day) ->
|
selectedDay?.let { (status, day) ->
|
||||||
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
|
ReportDialog(
|
||||||
|
statusName = status.name,
|
||||||
|
day = day,
|
||||||
|
reports = uiState.reports,
|
||||||
|
upload = uiState.upload,
|
||||||
|
onToggleUpload = onToggleUpload,
|
||||||
|
onReportChange = onUploadReportChange,
|
||||||
|
onCallsignChange = onUploadCallsignChange,
|
||||||
|
onGridChange = onUploadGridChange,
|
||||||
|
onRequestSubmitReport = onRequestSubmitUpload,
|
||||||
|
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
|
||||||
|
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
|
||||||
|
onDismiss = {
|
||||||
|
selectedDay = null
|
||||||
|
onDismissUpload()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,13 +351,57 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Report detail dialog (callsign / date / time / grid) */
|
/** Report detail dialog (callsign / date / time / grid) + AMSAT report upload panel. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
|
private fun ReportDialog(
|
||||||
|
statusName: String,
|
||||||
|
day: SatDay,
|
||||||
|
reports: Map<String, SatReport>,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onToggleUpload: () -> Unit,
|
||||||
|
onReportChange: (String) -> Unit,
|
||||||
|
onCallsignChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitReport: () -> Unit,
|
||||||
|
onConfirmSubmitReport: () -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||||
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
|
InfoDialog(
|
||||||
|
title = "$statusName · ${day.dateLabel}",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onAccept = onDismiss,
|
||||||
|
extraAction = {
|
||||||
|
CardButton(
|
||||||
|
onClick = onToggleUpload,
|
||||||
|
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = upload.isExpanded) {
|
||||||
|
AmSatUploadPanel(
|
||||||
|
statusName = statusName,
|
||||||
|
upload = upload,
|
||||||
|
onReportChange = onReportChange,
|
||||||
|
onCallsignChange = onCallsignChange,
|
||||||
|
onGridChange = onGridChange,
|
||||||
|
onRequestSubmitReport = onRequestSubmitReport
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (upload.isConfirmingSubmit) {
|
||||||
|
AmSatUploadConfirmDialog(
|
||||||
|
statusName = statusName,
|
||||||
|
upload = upload,
|
||||||
|
onConfirm = onConfirmSubmitReport,
|
||||||
|
onDismiss = onDismissSubmitConfirmation
|
||||||
|
)
|
||||||
|
}
|
||||||
if (dayReports.isEmpty()) {
|
if (dayReports.isEmpty()) {
|
||||||
Text(stringResource(id = R.string.amsat_no_reports))
|
Text(
|
||||||
|
text = stringResource(id = R.string.amsat_no_reports),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
||||||
@@ -296,7 +415,7 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
|
|||||||
.background(statusColorOf(report.statusText))
|
.background(statusColorOf(report.statusText))
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
Text(text = statusTextOf(report.statusText), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
||||||
@@ -312,16 +431,192 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AmSatUploadPanel(
|
||||||
|
statusName: String,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onReportChange: (String) -> Unit,
|
||||||
|
onCallsignChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitReport: () -> Unit
|
||||||
|
) {
|
||||||
|
val options = remember(statusName) {
|
||||||
|
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_report),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_status),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
options.forEach { option ->
|
||||||
|
val selected = upload.selectedReport == option.apiValue
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onReportChange(option.apiValue) },
|
||||||
|
label = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) Color.White else option.color)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(stringResource(option.labelResId), maxLines = 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
containerColor = option.color.copy(alpha = 0.10f),
|
||||||
|
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
selectedContainerColor = option.color,
|
||||||
|
selectedLabelColor = Color.White
|
||||||
|
),
|
||||||
|
border = FilterChipDefaults.filterChipBorder(
|
||||||
|
enabled = true,
|
||||||
|
selected = selected,
|
||||||
|
borderColor = option.color.copy(alpha = 0.60f),
|
||||||
|
selectedBorderColor = option.color
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = upload.callsign,
|
||||||
|
onValueChange = onCallsignChange,
|
||||||
|
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = upload.gridSquare,
|
||||||
|
onValueChange = onGridChange,
|
||||||
|
label = { Text(stringResource(R.string.amsat_upload_grid)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onRequestSubmitReport,
|
||||||
|
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (upload.isSubmitting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.amsat_upload_submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload.message?.let { message ->
|
||||||
|
if (message == UPLOAD_MESSAGE_SUCCESS) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_success),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload.error?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = uploadErrorText(error),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AmSatUploadConfirmDialog(
|
||||||
|
statusName: String,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
|
||||||
|
val statusLabel = stringResource(option.labelResId)
|
||||||
|
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.amsat_upload_confirm_message,
|
||||||
|
statusName,
|
||||||
|
statusLabel,
|
||||||
|
upload.callsign,
|
||||||
|
grid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.amsat_upload_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun uploadErrorText(error: String): String {
|
||||||
|
return when (error) {
|
||||||
|
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
|
||||||
|
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
|
||||||
|
else -> stringResource(R.string.amsat_upload_failed, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
|
||||||
private fun formatFetchedAt(utcMs: Long): String {
|
private fun formatFetchedAt(utcMs: Long): String {
|
||||||
val cal = Calendar.getInstance()
|
val cal = Calendar.getInstance()
|
||||||
cal.timeInMillis = utcMs
|
cal.timeInMillis = utcMs
|
||||||
val day = cal.get(Calendar.DAY_OF_MONTH)
|
val day = cal.get(Calendar.DAY_OF_MONTH)
|
||||||
val month = MONTH_ABBR[cal.get(Calendar.MONTH)]
|
val monthIndex = cal.get(Calendar.MONTH)
|
||||||
val year = cal.get(Calendar.YEAR)
|
val year = cal.get(Calendar.YEAR)
|
||||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
||||||
return "$day$month $year - $hh:$mm:$ss"
|
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||||
|
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
|
||||||
|
} else {
|
||||||
|
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatUtcNowForUpload(utcMs: Long): String {
|
||||||
|
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
return formatter.format(Date(utcMs))
|
||||||
}
|
}
|
||||||
+216
-20
@@ -4,14 +4,38 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
const val AMSAT_REPORT_HEARD = "Heard"
|
||||||
|
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
|
||||||
|
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
|
||||||
|
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
|
||||||
|
|
||||||
|
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
|
||||||
|
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
|
||||||
|
const val UPLOAD_MESSAGE_SUCCESS = "success"
|
||||||
|
|
||||||
|
data class AmSatUploadUiState(
|
||||||
|
val isExpanded: Boolean = false,
|
||||||
|
val selectedReport: String = AMSAT_REPORT_HEARD,
|
||||||
|
val callsign: String = "",
|
||||||
|
val gridSquare: String = "",
|
||||||
|
val isSubmitting: Boolean = false,
|
||||||
|
val isConfirmingSubmit: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
data class SatStatusUiState(
|
data class SatStatusUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -19,11 +43,13 @@ data class SatStatusUiState(
|
|||||||
val statuses: List<SatStatus> = emptyList(),
|
val statuses: List<SatStatus> = emptyList(),
|
||||||
val reports: Map<String, SatReport> = emptyMap(),
|
val reports: Map<String, SatReport> = emptyMap(),
|
||||||
val fetchedAtUtcMs: Long = 0L,
|
val fetchedAtUtcMs: Long = 0L,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
val upload: AmSatUploadUiState = AmSatUploadUiState()
|
||||||
)
|
)
|
||||||
|
|
||||||
class SatStatusViewModel(
|
class SatStatusViewModel(
|
||||||
private val amSatRepo: IAmSatRepository
|
private val amSatRepo: IAmSatRepository,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
||||||
@@ -39,15 +65,7 @@ class SatStatusViewModel(
|
|||||||
try {
|
try {
|
||||||
val page = amSatRepo.fetchStatus()
|
val page = amSatRepo.fetchStatus()
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
_uiState.update {
|
applyStatusPage(page)
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||||
@@ -68,14 +86,7 @@ class SatStatusViewModel(
|
|||||||
try {
|
try {
|
||||||
val page = amSatRepo.fetchStatus()
|
val page = amSatRepo.fetchStatus()
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
_uiState.update {
|
applyStatusPage(page)
|
||||||
it.copy(
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||||
}
|
}
|
||||||
@@ -85,11 +96,196 @@ class SatStatusViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyStatusPage(page: SatStatusPage) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
statuses = page.statuses,
|
||||||
|
reports = page.reports,
|
||||||
|
fetchedAtUtcMs = page.fetchedAtUtcMs,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleUploadPanel() {
|
||||||
|
val storedCallsign = settingsRepo.getAmSatCallsign()
|
||||||
|
val defaultGrid = defaultGridSquare()
|
||||||
|
_uiState.update { state ->
|
||||||
|
val upload = state.upload
|
||||||
|
val expanded = !upload.isExpanded
|
||||||
|
state.copy(
|
||||||
|
upload = if (expanded) {
|
||||||
|
upload.copy(
|
||||||
|
isExpanded = true,
|
||||||
|
callsign = upload.callsign.ifBlank { storedCallsign },
|
||||||
|
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
|
||||||
|
message = null,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collapseUploadPanel() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUploadReport(report: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUploadCallsign(callsign: String) {
|
||||||
|
val normalized = callsign.trim().uppercase(Locale.US)
|
||||||
|
settingsRepo.setAmSatCallsign(normalized)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUploadGrid(grid: String) {
|
||||||
|
val normalized = grid.trim().uppercase(Locale.US)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestSubmitConfirmation() {
|
||||||
|
val prepared = validateUpload(_uiState.value.upload) ?: return
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
isConfirmingSubmit = true,
|
||||||
|
message = null,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissSubmitConfirmation() {
|
||||||
|
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmSubmitReport(satelliteName: String) {
|
||||||
|
val upload = _uiState.value.upload
|
||||||
|
if (upload.isSubmitting) return
|
||||||
|
val prepared = validateUpload(upload) ?: return
|
||||||
|
|
||||||
|
settingsRepo.setAmSatCallsign(prepared.callsign)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
isSubmitting = true,
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = null,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = amSatRepo.submitReport(
|
||||||
|
AmSatReportSubmission(
|
||||||
|
name = satelliteName,
|
||||||
|
report = prepared.report,
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
reportedAtUtcMillis = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isSubmitting = false,
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
message = UPLOAD_MESSAGE_SUCCESS,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isSubmitting = false,
|
||||||
|
message = null,
|
||||||
|
error = result.message.ifBlank { "Upload failed" }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
|
||||||
|
val callsign = upload.callsign.trim().uppercase(Locale.US)
|
||||||
|
val grid = upload.gridSquare.trim().uppercase(Locale.US)
|
||||||
|
return when {
|
||||||
|
callsign.isBlank() -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = UPLOAD_ERROR_GRID_INVALID,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> PreparedUpload(
|
||||||
|
report = upload.selectedReport,
|
||||||
|
callsign = callsign,
|
||||||
|
grid = grid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultGridSquare(): String {
|
||||||
|
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
|
||||||
|
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SatStatusViewModel(container.amSatRepo)
|
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class PreparedUpload(
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val grid: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
|
||||||
+14
-10
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "446"
|
appVersionCode = "448"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.6"
|
appVersionName = "4.4.8"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -12,24 +12,25 @@ jdkVersion = "21"
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.3.1"
|
android-gradle-plugin = "9.4.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.19.0"
|
androidx-core-ktx = "1.19.1"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.5"
|
||||||
|
|
||||||
compose-bom = "2026.06.01"
|
compose-bom = "2026.09.00"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.11.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.5"
|
compose-navigation3 = "1.2.0"
|
||||||
|
|
||||||
google-ksp = "2.3.10"
|
google-ksp = "2.3.11"
|
||||||
|
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.20"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
other-okhttp = "5.4.0"
|
other-detekt = "1.23.8"
|
||||||
|
other-okhttp = "5.5.0"
|
||||||
other-osmdroid = "6.1.20"
|
other-osmdroid = "6.1.20"
|
||||||
|
|
||||||
test-junit4 = "4.13.2"
|
test-junit4 = "4.13.2"
|
||||||
@@ -72,6 +73,8 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
|
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
|
||||||
|
|
||||||
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
other-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "other-detekt" }
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
|
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -90,6 +93,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
|
|||||||
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
|
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
other-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "other-detekt" }
|
||||||
# plugins defined by this project
|
# plugins defined by this project
|
||||||
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
|
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
|
||||||
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
|
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Tue Aug 11 14:16:53 CEST 2026
|
#Sun Sep 27 11:54:01 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in new issue
Block a user