mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aada90b76 |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
@@ -36,10 +36,10 @@ It is now and always will be completely ad-free and open-source.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -42,7 +44,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
@@ -68,8 +70,6 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
@@ -82,42 +82,37 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Scaffold { innerPadding ->
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,20 +120,18 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(300))
|
||||
val navItems =
|
||||
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
@@ -151,18 +144,13 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Status -> screen is Screen.Status
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
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)
|
||||
)
|
||||
},
|
||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
@@ -191,7 +179,9 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
popTransitionSpec = { fadeTransition },
|
||||
predictivePopTransitionSpec = { fadeTransition },
|
||||
entryDecorators = listOf(
|
||||
// Required for saving Compose state per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
// Required for ViewModel scoping per entry
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
@@ -201,71 +191,69 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Status> { SatStatusDestination() }
|
||||
entry<Screen.Map> { MapDestination() }
|
||||
entry<Screen.Settings> { SettingsDestination() }
|
||||
entry<Screen.Radar> {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
}
|
||||
)
|
||||
// Radio tracking status banner
|
||||
if (trackingState.isActive) {
|
||||
TrackingBanner(
|
||||
state = trackingState,
|
||||
onClick = {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
}
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1f, targetValue = 0.4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
), label = "pulseAlpha"
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||
Text(
|
||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulseAlpha"
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_format, state.currentPass?.name ?: ""),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val connections = listOfNotNull(
|
||||
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
|
||||
)
|
||||
Text(
|
||||
text = connections.joinToString("/"),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
-1
@@ -36,7 +36,6 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:satellites"))
|
||||
implementation(project(":feature:settings"))
|
||||
implementation(project(":feature:status"))
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.compose.material3.adaptive)
|
||||
implementation(libs.compose.navigation3)
|
||||
|
||||
@@ -5,46 +5,6 @@ plugins {
|
||||
alias(libs.plugins.google.ksp) apply false
|
||||
alias(libs.plugins.kotlin.jvm) 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) {
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
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,7 +19,6 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
@@ -40,18 +39,6 @@ interface Look4SatDao {
|
||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||
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)
|
||||
suspend fun insertEntries(entries: List<SatEntry>)
|
||||
|
||||
@@ -78,7 +65,4 @@ interface Look4SatDao {
|
||||
|
||||
@Query("DELETE FROM radios")
|
||||
suspend fun deleteRadios()
|
||||
|
||||
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||
suspend fun deleteManagedRadios()
|
||||
}
|
||||
@@ -19,23 +19,19 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Database
|
||||
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.SatRadio
|
||||
|
||||
const val DATABASE_NAME = "Look4SatDBv400"
|
||||
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
||||
abstract class Look4SatDb : RoomDatabase() {
|
||||
abstract fun look4SatDao(): Look4SatDao
|
||||
}
|
||||
|
||||
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// 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))")
|
||||
// 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")
|
||||
// database.execSQL("DROP TABLE entries")
|
||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
||||
// }
|
||||
//}
|
||||
@@ -30,6 +30,5 @@ data class SatEntry(
|
||||
val argper: Double,
|
||||
val meanan: Double,
|
||||
val catnum: Int,
|
||||
val bstar: Double,
|
||||
val ndot: Double = 0.0
|
||||
val bstar: Double
|
||||
)
|
||||
@@ -32,7 +32,5 @@ data class SatRadio(
|
||||
val uplinkHigh: Long?,
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
val catnum: Int?,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
val catnum: Int?
|
||||
)
|
||||
+26
-65
@@ -17,10 +17,8 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -33,30 +31,18 @@ class NetworkReporter(
|
||||
private val rotatorServer: String,
|
||||
private val rotatorPort: Int,
|
||||
private val frequencyServer: String,
|
||||
private val frequencyPort: Int,
|
||||
private val frequencyOffsetHz: Long = 0L
|
||||
private val frequencyPort: Int
|
||||
) : IReporter {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
private val connectionMutex = Mutex()
|
||||
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||
|
||||
private var rotatorSocket: SocketChannel? = null
|
||||
private var rotatorConnected = false
|
||||
private var rotatorConnecting = false
|
||||
|
||||
private var frequencySocket: SocketChannel? = null
|
||||
private var frequencyConnected = false
|
||||
|
||||
init {
|
||||
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||
reporterScope.launch {
|
||||
for (command in frequencyCommands) {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) continue
|
||||
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
private var frequencyConnecting = false
|
||||
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||
reporterScope.launch {
|
||||
@@ -72,43 +58,46 @@ class NetworkReporter(
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long) {
|
||||
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||
val command = format
|
||||
.replace($$"$FREQ", correctedFreq.toString())
|
||||
.unescapeControlChars()
|
||||
frequencyCommands.trySend(command)
|
||||
reporterScope.launch {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) return@launch
|
||||
val command = format
|
||||
.replace($$"$FREQ", frequency.toString())
|
||||
.unescapeControlChars()
|
||||
write(frequencySocket, command) { frequencyConnected = false }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureRotatorConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
try {
|
||||
resetRotatorConnection()
|
||||
rotatorConnecting = true
|
||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
rotatorConnected = true
|
||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter rotator connect error: ${e.message}")
|
||||
resetRotatorConnection()
|
||||
rotatorConnected = false
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureFrequencyConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
try {
|
||||
resetFrequencyConnection()
|
||||
frequencyConnecting = true
|
||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
frequencyConnected = true
|
||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter frequency connect error: ${e.message}")
|
||||
resetFrequencyConnection()
|
||||
frequencyConnected = false
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,9 +106,7 @@ class NetworkReporter(
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||
while (buffer.hasRemaining()) {
|
||||
socket?.write(buffer)
|
||||
}
|
||||
socket?.write(buffer)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
@@ -127,32 +114,6 @@ class NetworkReporter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetRotatorConnection() {
|
||||
rotatorConnected = false
|
||||
closeQuietly(rotatorSocket)
|
||||
rotatorSocket = null
|
||||
}
|
||||
|
||||
private fun resetFrequencyConnection() {
|
||||
frequencyConnected = false
|
||||
closeQuietly(frequencySocket)
|
||||
frequencySocket = null
|
||||
}
|
||||
|
||||
private fun closeQuietly(socket: SocketChannel?) {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun Long.safeAdd(delta: Long): Long {
|
||||
return when {
|
||||
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
||||
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
||||
else -> this + delta
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.unescapeControlChars(): String =
|
||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||
}
|
||||
@@ -22,10 +22,7 @@ import android.content.Context
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
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.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
@@ -109,8 +106,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
rc.rotatorAddress,
|
||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyAddress,
|
||||
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyOffsetHz
|
||||
rc.frequencyPort.toIntOrNull() ?: 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,24 +148,18 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||
val dbDispatcher = Dispatchers.Default
|
||||
val dataParser = DataParser(dbDispatcher)
|
||||
val remoteSource = provideRemoteSource()
|
||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
}
|
||||
|
||||
private fun provideLocalSource(): ILocalSource {
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
|
||||
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
||||
return LocalSource(database.look4SatDao())
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
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)
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||
}
|
||||
|
||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||
@@ -184,9 +174,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
val appPrefsFileName = "${context.packageName}_preferences"
|
||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val appVersionName = packageInfo.versionName ?: "4.0.4"
|
||||
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||
}
|
||||
}
|
||||
+13
-66
@@ -1,7 +1,5 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
@@ -14,7 +12,6 @@ import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@@ -38,66 +35,24 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||
}
|
||||
|
||||
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||
val payload = JSONObject().apply {
|
||||
put("name", submission.name)
|
||||
put("report", submission.report)
|
||||
put("callsign", submission.callsign)
|
||||
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||
}
|
||||
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||
val (code, body) = response
|
||||
val errorMessage = parseSubmitError(body)
|
||||
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||
} else {
|
||||
AmSatReportSubmitResult(
|
||||
success = false,
|
||||
message = errorMessage.ifBlank { "HTTP $code" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitError(json: String): String {
|
||||
return try {
|
||||
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitReportId(json: String): String? {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
private fun parseCatalog(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -118,7 +73,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -127,21 +82,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val labels = (0 until 3).map { d ->
|
||||
val labels = (0 until 6).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
formatDayLabel(utc)
|
||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
return names.map { name ->
|
||||
val slots = (0 until 36).map { slotIdx ->
|
||||
val slots = (0 until 72).map { slotIdx ->
|
||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||
val slotEnd = nowSec - slotIdx * 7200L
|
||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
@@ -156,22 +112,13 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
)
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
val days = (0 until 6).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDayLabel(calendar: Calendar): String {
|
||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||
} else {
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSatReport(r: ApiReport): SatReport {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||
|
||||
+26
-92
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
@@ -39,13 +40,13 @@ class DatabaseRepo(
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : IDatabaseRepo {
|
||||
|
||||
private val customSourceType = "Other"
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||
insertFresherEntries(entries)
|
||||
// report what the file contained: a valid file holding only stale data is not an error
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
importedCount = entries.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -56,7 +57,7 @@ class DatabaseRepo(
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
localSource.insertRadios(transceivers, isCustom = true)
|
||||
localSource.insertRadios(transceivers)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -64,40 +65,29 @@ class DatabaseRepo(
|
||||
}
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val settings = settingsRepo.dataSourcesSettings.value
|
||||
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
||||
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
// report the HTTP status code of every source (200, 404, ...)
|
||||
settingsRepo.updateDataSourcesStatus(
|
||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||
)
|
||||
// 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 dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// parse fetched data concurrently
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val importedRadios = radioResults.flatMap { (url, result) ->
|
||||
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||
// insert parsed data into the database
|
||||
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||
// transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||
// 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)
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||
@@ -133,62 +123,6 @@ class DatabaseRepo(
|
||||
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) {
|
||||
settingsRepo.updateDatabaseState(
|
||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||
|
||||
+2
-19
@@ -114,29 +114,12 @@ class SelectionRepo(
|
||||
/**
|
||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||
* 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> {
|
||||
if (query.isBlank()) return items
|
||||
val catnum = query.toIntOrNull()
|
||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||
val tokens = query.split(' ')
|
||||
.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) }
|
||||
}
|
||||
val lowerQuery = query.lowercase()
|
||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||
}
|
||||
|
||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||
private fun normalizeForSearch(text: String): String =
|
||||
text.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
+20
-111
@@ -29,10 +29,8 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
@@ -40,13 +38,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
class SettingsRepo(
|
||||
private val locationManager: LocationManager,
|
||||
private val preferences: SharedPreferences,
|
||||
override val appVersionName: String,
|
||||
override val appVersionCode: Long,
|
||||
override val appVersionName: String
|
||||
) : ISettingsRepo, LocationListenerCompat {
|
||||
|
||||
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||
@@ -72,7 +68,6 @@ class SettingsRepo(
|
||||
private val keyFrequencyAddress = "frequencyAddress"
|
||||
private val keyFrequencyPort = "frequencyPort"
|
||||
private val keyFrequencyFormat = "frequencyFormat"
|
||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
private val keySelectedIds = "selectedIds"
|
||||
private val keySelectedSatModes = "selectedSatModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
@@ -88,32 +83,17 @@ class SettingsRepo(
|
||||
private val keyStationTimestamp = "stationTimestamp"
|
||||
private val keyUpdateTimestamp = "updateTimestamp"
|
||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keySatellitesUrls = "satellitesUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
|
||||
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
|
||||
private val keySettingsVersion = "settingsVersion"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
|
||||
init {
|
||||
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
|
||||
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
|
||||
// on the first launch instead of waiting up to 48 hours for the automatic update.
|
||||
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
|
||||
preferences.edit {
|
||||
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
|
||||
.forEach { key -> remove(key) }
|
||||
putInt(keySettingsVersion, appVersionCode.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
@@ -297,10 +277,6 @@ class SettingsRepo(
|
||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
@@ -310,7 +286,6 @@ class SettingsRepo(
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
@@ -319,7 +294,7 @@ class SettingsRepo(
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
_rcSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getRCSettings(): RCSettings = RCSettings(
|
||||
@@ -331,8 +306,6 @@ class SettingsRepo(
|
||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
@@ -391,73 +364,21 @@ class SettingsRepo(
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _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 {
|
||||
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
||||
putString(keyTleUrl, settings.tleUrl)
|
||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
||||
}
|
||||
_dataSourcesSettings.value = normalized
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
val (satUrls, satEnabled) = parseSources(
|
||||
preferences.getString(keySatellitesUrls, null),
|
||||
preferences.getString(keySatellitesUrlsEnabled, null),
|
||||
Sources.satelliteDataUrls
|
||||
)
|
||||
val (txUrls, txEnabled) = parseSources(
|
||||
preferences.getString(keyTransceiversUrls, null),
|
||||
preferences.getString(keyTransceiversUrlsEnabled, null),
|
||||
Sources.transceiversDataUrls
|
||||
)
|
||||
return DataSourcesSettings(
|
||||
satelliteUrls = satUrls,
|
||||
transceiversUrls = txUrls,
|
||||
satelliteEnabled = satEnabled,
|
||||
transceiversEnabled = txEnabled
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSources(
|
||||
storedUrls: String?,
|
||||
storedEnabled: String?,
|
||||
defaults: List<String>
|
||||
): Pair<List<String>, List<Boolean>> {
|
||||
if (storedUrls == null) return defaults to defaults.map { true }
|
||||
val urls = storedUrls.split(separatorUrl)
|
||||
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
|
||||
val filteredUrls = mutableListOf<String>()
|
||||
val filteredFlags = mutableListOf<Boolean>()
|
||||
urls.forEachIndexed { index, url ->
|
||||
if (url.isNotBlank()) {
|
||||
filteredUrls.add(url)
|
||||
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
|
||||
}
|
||||
}
|
||||
return filteredUrls to filteredFlags
|
||||
}
|
||||
|
||||
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||
return flags + List(urls.size - flags.size) { true }
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
_dataSourcesStatus.value = status
|
||||
}
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -504,7 +425,7 @@ class SettingsRepo(
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -515,21 +436,9 @@ class SettingsRepo(
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
"""{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||
}
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
override fun getAmSatCallsign(): String {
|
||||
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||
}
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) {
|
||||
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
@@ -43,16 +43,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
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 deleteEntries() = look4SatDao.deleteEntries()
|
||||
@@ -61,12 +51,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
|
||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
)
|
||||
|
||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
)
|
||||
|
||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||
@@ -83,17 +73,15 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||
|
||||
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||
|
||||
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
|
||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||
)
|
||||
|
||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||
@@ -101,6 +89,8 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
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() }
|
||||
|
||||
//endregion
|
||||
|
||||
@@ -20,13 +20,10 @@ package com.rtbishop.look4sat.core.data.source
|
||||
import android.content.ContentResolver
|
||||
import androidx.core.net.toUri
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -45,26 +42,20 @@ class RemoteSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
val code = response.code
|
||||
response.close()
|
||||
return@withContext NetworkResult(code, null)
|
||||
return@withContext null
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
response.close()
|
||||
return@withContext NetworkResult(response.code, null)
|
||||
}
|
||||
NetworkResult(response.code, body.byteStream().buffered())
|
||||
response.body.byteStream().buffered()
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,21 +90,4 @@ class RemoteSource(
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-218
@@ -31,7 +31,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -42,7 +41,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
@@ -50,10 +48,6 @@ class DatabaseRepoTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
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
|
||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
@@ -96,8 +90,10 @@ class DatabaseRepoTest {
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(customCsvUrl),
|
||||
transceiversUrls = emptyList()
|
||||
useCustomTLE = true,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = customCsvUrl,
|
||||
transceiversUrl = ""
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -107,198 +103,30 @@ class DatabaseRepoTest {
|
||||
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 = """
|
||||
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,${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
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
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 {
|
||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult {
|
||||
val stream = networkStreams[url]?.invoke()
|
||||
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
val insertedEntries = mutableListOf<OrbitalData>()
|
||||
val insertedRadios = mutableListOf<SatRadio>()
|
||||
private val customRadios = mutableListOf<String>()
|
||||
private val insertedRadios = mutableListOf<SatRadio>()
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
@@ -306,21 +134,6 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
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>) {
|
||||
insertedEntries += entries
|
||||
}
|
||||
@@ -335,27 +148,18 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
insertedRadios += radios
|
||||
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
|
||||
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() {
|
||||
insertedRadios.clear()
|
||||
customRadios.clear()
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() {
|
||||
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
override val appVersionCode: Long = 1L
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
@@ -370,7 +174,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
@@ -379,8 +183,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||
|
||||
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
@@ -410,24 +212,18 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
dataSourcesStatus.value = status
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
return DataSourcesSettings(
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = emptyList()
|
||||
useCustomTLE = false,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "",
|
||||
transceiversUrl = ""
|
||||
)
|
||||
}
|
||||
+5
-81
@@ -84,65 +84,6 @@ class SelectionRepoTest {
|
||||
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 val entries: List<SatItem>
|
||||
) : ILocalSource {
|
||||
@@ -152,14 +93,6 @@ class SelectionRepoTest {
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
|
||||
|
||||
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) = Unit
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
|
||||
|
||||
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||
|
||||
override suspend fun deleteEntries() = Unit
|
||||
@@ -170,11 +103,9 @@ class SelectionRepoTest {
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
||||
|
||||
override suspend fun deleteRadios() = Unit
|
||||
|
||||
override suspend fun deleteManagedRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(
|
||||
@@ -182,7 +113,6 @@ class SelectionRepoTest {
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
override val appVersionCode: Long = 1L
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
@@ -197,7 +127,7 @@ class SelectionRepoTest {
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
@@ -205,11 +135,9 @@ class SelectionRepoTest {
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(emptyList(), emptyList())
|
||||
DataSourcesSettings(false, false, "", "")
|
||||
)
|
||||
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
@@ -238,16 +166,12 @@ class SelectionRepoTest {
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
@@ -20,19 +20,17 @@ package com.rtbishop.look4sat.core.domain.model
|
||||
import kotlinx.serialization.SerialName
|
||||
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
|
||||
data class SatRadio(
|
||||
@SerialName("uuid") val uuid: String,
|
||||
@SerialName("description") val info: String = "",
|
||||
@SerialName("alive") val isAlive: Boolean = true,
|
||||
@SerialName("downlink_low") val downlinkLow: Long? = null,
|
||||
@SerialName("downlink_high") val downlinkHigh: Long? = null,
|
||||
@SerialName("mode") val downlinkMode: String? = null,
|
||||
@SerialName("uplink_low") val uplinkLow: Long? = null,
|
||||
@SerialName("uplink_high") val uplinkHigh: Long? = null,
|
||||
@SerialName("uplink_mode") val uplinkMode: String? = null,
|
||||
@SerialName("invert") val isInverted: Boolean = false,
|
||||
@SerialName("norad_cat_id") val catnum: Int? = null
|
||||
@SerialName("description") val info: String,
|
||||
@SerialName("alive") val isAlive: Boolean,
|
||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
||||
@SerialName("mode") val downlinkMode: String?,
|
||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||
@SerialName("invert") val isInverted: Boolean,
|
||||
@SerialName("norad_cat_id") val catnum: Int?
|
||||
)
|
||||
@@ -23,10 +23,10 @@ data class SatDay(
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 5 days of state */
|
||||
/** One satellite, 6 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 5 天(新→旧)
|
||||
val days: List<SatDay> // 6 天(新→旧)
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
|
||||
@@ -41,7 +41,6 @@ data class RCSettings(
|
||||
val frequencyAddress: String,
|
||||
val frequencyPort: String,
|
||||
val frequencyFormat: String,
|
||||
val frequencyOffsetHz: Long = 0L,
|
||||
val bluetoothRotatorState: Boolean,
|
||||
val bluetoothRotatorFormat: String,
|
||||
val bluetoothRotatorName: String,
|
||||
@@ -67,21 +66,12 @@ data class OtherSettings(
|
||||
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(
|
||||
val satelliteUrls: 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 }
|
||||
}
|
||||
val useCustomTLE: Boolean,
|
||||
val useCustomTransceivers: Boolean,
|
||||
val tleUrl: String,
|
||||
val transceiversUrl: String
|
||||
)
|
||||
|
||||
data class RadioControlSettings(
|
||||
val enabled: Boolean,
|
||||
|
||||
+2
-6
@@ -424,12 +424,8 @@ object CelestialComputer {
|
||||
}
|
||||
if (sunrise == 0.0) sunrise = daynum
|
||||
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||
// 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
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||
daynum = sunrise
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
|
||||
+17
-29
@@ -37,42 +37,30 @@ data class OrbitalData(
|
||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||
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)
|
||||
|
||||
/** Check if satellite has likely decayed by the given time. */
|
||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||
if (ndot == 0.0) return false
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||
val epochDaynum = epochToDaynum(epoch)
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
|
||||
private fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
|
||||
/**
|
||||
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||
* freshness checks must always go through this conversion.
|
||||
*/
|
||||
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
|
||||
}
|
||||
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,14 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** Fetch and parse the AMSAT status page; null on failure */
|
||||
suspend fun fetchStatus(): SatStatusPage?
|
||||
|
||||
/** Submit a public AMSAT satellite status report. */
|
||||
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||
}
|
||||
-11
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface ISettingsRepo {
|
||||
|
||||
val appVersionName: String
|
||||
val appVersionCode: Long
|
||||
|
||||
//region # Satellites selection settings
|
||||
val selectedIds: StateFlow<List<Int>>
|
||||
@@ -72,11 +71,6 @@ interface ISettingsRepo {
|
||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
@@ -86,9 +80,4 @@ interface ISettingsRepo {
|
||||
fun getSatelliteOffset(catnum: Int): String
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
//endregion
|
||||
}
|
||||
@@ -26,18 +26,11 @@ interface ILocalSource {
|
||||
suspend fun getEntriesTotal(): Int
|
||||
suspend fun getEntriesList(): List<SatItem>
|
||||
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 deleteEntries()
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
suspend fun getRadiosTotal(): Int
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
|
||||
suspend fun insertRadios(radios: List<SatRadio>)
|
||||
suspend fun deleteRadios()
|
||||
|
||||
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
|
||||
suspend fun deleteManagedRadios()
|
||||
}
|
||||
+1
-10
@@ -19,18 +19,9 @@ package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
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 {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): NetworkResult
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||
}
|
||||
@@ -18,19 +18,18 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
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(
|
||||
"live.ariss.org/iss.txt",
|
||||
"r4uab.ru/satonline.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 satelliteDataUrls = mapOf(
|
||||
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
val transceiversDataUrls = listOf(
|
||||
"r4uab.ru/transmitters.json",
|
||||
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
)
|
||||
val satelliteModes = listOf(
|
||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||
|
||||
@@ -33,19 +33,10 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
ignoreUnknownKeys = 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) {
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
val iterator = lines.iterator()
|
||||
if (!iterator.hasNext()) return@useLines emptyList()
|
||||
val columns = parseCSVColumns(iterator.next())
|
||||
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
|
||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,62 +58,34 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
|
||||
fun value(column: String) = values[columns.getValue(column)].trim()
|
||||
fun optionalValue(column: String) =
|
||||
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
OrbitalData(
|
||||
name = value("OBJECT_NAME"),
|
||||
epoch = parseTimestamp(value("EPOCH")),
|
||||
meanmo = value("MEAN_MOTION").toDouble(),
|
||||
eccn = value("ECCENTRICITY").toDouble(),
|
||||
incl = value("INCLINATION").toDouble(),
|
||||
raan = value("RA_OF_ASC_NODE").toDouble(),
|
||||
argper = value("ARG_OF_PERICENTER").toDouble(),
|
||||
meanan = value("MEAN_ANOMALY").toDouble(),
|
||||
catnum = parseCatnum(value("NORAD_CAT_ID")),
|
||||
bstar = optionalValue("BSTAR"),
|
||||
ndot = optionalValue("MEAN_MOTION_DOT")
|
||||
)
|
||||
}.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()
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val timestamp = values[2]
|
||||
val year = timestamp.substring(0, 4)
|
||||
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()
|
||||
}
|
||||
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(
|
||||
name = name,
|
||||
epoch = epoch,
|
||||
meanmo = values[3].toDouble(),
|
||||
eccn = values[4].toDouble(),
|
||||
incl = values[5].toDouble(),
|
||||
raan = values[6].toDouble(),
|
||||
argper = values[7].toDouble(),
|
||||
meanan = values[8].toDouble(),
|
||||
catnum = values[11].toInt(),
|
||||
bstar = values[14].toDouble(),
|
||||
ndot = values[15].toDouble()
|
||||
)
|
||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||
|
||||
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
||||
val line1 = tle[1]
|
||||
@@ -136,7 +99,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
raan = line2.substring(17, 25).toDouble(),
|
||||
argper = line2.substring(34, 42).toDouble(),
|
||||
meanan = line2.substring(43, 51).toDouble(),
|
||||
catnum = parseCatnum(line1.substring(2, 7)),
|
||||
catnum = line1.substring(2, 7).trim().toInt(),
|
||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||
ndot = line1.substring(33, 43).trim().toDouble()
|
||||
)
|
||||
|
||||
@@ -41,21 +41,6 @@ fun Double.round(decimals: Int): Double {
|
||||
return kotlin.math.round(this * multiplier) / multiplier
|
||||
}
|
||||
|
||||
fun String.aprsPasscode(): Int {
|
||||
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
||||
var hash = 0x73E2
|
||||
var i = 0
|
||||
while (i < callsign.length) {
|
||||
hash = hash xor (callsign[i].code shl 8)
|
||||
i++
|
||||
if (i < callsign.length) {
|
||||
hash = hash xor callsign[i].code
|
||||
i++
|
||||
}
|
||||
}
|
||||
return hash and 0x7FFF
|
||||
}
|
||||
|
||||
//fun String.getHash(type: String = "SHA-256"): String {
|
||||
// val hexChars = "0123456789ABCDEF"
|
||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||
|
||||
@@ -18,12 +18,10 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class DataParserTest {
|
||||
@@ -62,49 +60,8 @@ class DataParserTest {
|
||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
|
||||
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)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
assert(parsedList[1].epoch == 24069.23963816)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -282,10 +239,4 @@ class DataParserTest {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check APRS passcode calculation`() {
|
||||
assert("M7LNB".aprsPasscode() == 12443)
|
||||
assert("N0CALL".aprsPasscode() == 13023)
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.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
|
||||
}
|
||||
}
|
||||
+35
-79
@@ -27,15 +27,11 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -51,8 +47,9 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -76,8 +73,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
@@ -239,13 +234,8 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconCard(
|
||||
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
||||
) {
|
||||
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
||||
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||
}
|
||||
@@ -301,86 +291,52 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
extraAction: (@Composable () -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
extraAction?.let {
|
||||
it()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
fun SharedDialog(
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
onDismissRequest: () -> Unit,
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onAccept: (() -> Unit)? = null,
|
||||
titleFontSize: Int = 16,
|
||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onCancel) { padding ->
|
||||
val dismissible = onCancel != null
|
||||
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
if (onCancel != null) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontSize = titleFontSize.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
textAlign = titleTextAlign,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = padding)
|
||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||
)
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
if (onAccept != null) {
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||
InfoDialog(
|
||||
title = stringResource(R.string.pass_whatsnew_title),
|
||||
onDismiss = onDismiss,
|
||||
onAccept = onDismiss
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
content(padding)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,13 +344,14 @@ fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||
@Composable
|
||||
private fun DialogShell(
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissible: Boolean = true,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||
val maxSheetHeight = containerHeight - statusBarHeight
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
|
||||
)
|
||||
val stopSheetFling = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
@@ -409,10 +366,10 @@ private fun DialogShell(
|
||||
}
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissRequest = if (dismissible) onDismissRequest else { {} },
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||
) {
|
||||
Column(
|
||||
@@ -420,7 +377,6 @@ private fun DialogShell(
|
||||
verticalArrangement = Arrangement.spacedBy(padding),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.nestedScroll(stopSheetFling)
|
||||
) {
|
||||
content(padding)
|
||||
@@ -430,11 +386,11 @@ private fun DialogShell(
|
||||
|
||||
@Composable
|
||||
fun hasEnoughHeight(): Boolean =
|
||||
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||
|
||||
@Composable
|
||||
fun hasEnoughWidth(): Boolean =
|
||||
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||
|
||||
@Composable
|
||||
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||
|
||||
-272
@@ -1,272 +0,0 @@
|
||||
/*
|
||||
* 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
@@ -1,311 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -46,7 +46,7 @@ data object Spacing {
|
||||
val extraSmall = 6.dp
|
||||
val small = 8.dp
|
||||
val medium = 12.dp
|
||||
val large = 12.dp
|
||||
val large = 16.dp
|
||||
val extraLarge = 24.dp
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
|
||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
||||
|
||||
@Serializable
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||
|
||||
@Serializable
|
||||
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
|
||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||
|
||||
@Serializable
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,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="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,31 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<path
|
||||
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
File renamed without changes.
@@ -118,11 +118,6 @@
|
||||
<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_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_transceivers_switch">URL transceptores</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -117,11 +117,6 @@
|
||||
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</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_transceivers_switch">URL трансиверов</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -118,11 +118,6 @@
|
||||
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</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_transceivers_switch">Transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -149,11 +149,6 @@
|
||||
<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_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_transceivers_switch">Transceiver URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -118,11 +118,6 @@
|
||||
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</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_transceivers_switch">URL трансиверів</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -12,45 +12,6 @@
|
||||
<string name="nav_map">地图</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 -->
|
||||
<string name="sat_type_hint">模式:%s</string>
|
||||
<string name="sat_type_title">选择模式</string>
|
||||
@@ -103,24 +64,6 @@
|
||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||
|
||||
<!-- Radar screen (CAT control / SSTV) -->
|
||||
<string name="radar_connect">连接</string>
|
||||
<string name="radar_disconnect">断开连接</string>
|
||||
<string name="radar_off">关</string>
|
||||
<string name="radar_stop">停止</string>
|
||||
<string name="radar_track">跟踪</string>
|
||||
<string name="radar_tx_base">TX 基频:</string>
|
||||
|
||||
<!-- SSTV screen -->
|
||||
<string name="sstv_grant_permission">授予权限</string>
|
||||
<string name="sstv_listening">正在监听…</string>
|
||||
<string name="sstv_mic_permission">需要麦克风权限</string>
|
||||
<string name="sstv_mode_format">模式:%s</string>
|
||||
<string name="sstv_mode_title">SSTV 模式</string>
|
||||
<string name="sstv_no_signal">无信号</string>
|
||||
<string name="sstv_no_transceiver">未选择收发器</string>
|
||||
<string name="sstv_rx_format">RX:%s Hz</string>
|
||||
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">上一个</string>
|
||||
<string name="map_next">下一个</string>
|
||||
@@ -141,12 +84,6 @@
|
||||
<string name="prefs_donate_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_time">yyyy MMM d - HH:mm:ss</string>
|
||||
|
||||
@@ -176,11 +113,6 @@
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</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_transceivers_switch">收发器 URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
@@ -241,7 +173,4 @@
|
||||
\n• Libre Space Foundation (SatNOGS)</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<!-- Main screen -->
|
||||
<string name="tracking_format">跟踪:%s</string>
|
||||
|
||||
</resources>
|
||||
@@ -12,7 +12,6 @@
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_map">Map</string>
|
||||
<string name="nav_prefs">Settings</string>
|
||||
<string name="nav_status" translatable="false">AMSAT</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Modes: %s</string>
|
||||
@@ -56,14 +55,16 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
|
||||
\n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||
\nSources now have priority - the top ones define satellite names and transceiver data.
|
||||
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
|
||||
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
|
||||
* Added small tweaks to Sources and strings. Added official ISS source (#225)
|
||||
\n* Implemented AOS window and elevation highlight filters (#226)
|
||||
\n* Added cleartext traffic support for custom TLE URLs (#227)
|
||||
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
|
||||
\n* Added support for Icom IC-705 CAT by ruilvo (#229)
|
||||
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
|
||||
\n* Localized pass date and time formats for Chinese by atsunatsu (#231)
|
||||
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
|
||||
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
|
||||
\n* Replaced the types selection dialog with modes selection to avoid confusion
|
||||
</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
@@ -80,30 +81,6 @@
|
||||
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||
<string name="amsat_no_reports">No reports for this period</string>
|
||||
<string name="amsat_close">Close</string>
|
||||
<string name="amsat_status_heard">Heard</string>
|
||||
<string name="amsat_status_tlm_only">Telemetry Only</string>
|
||||
<string name="amsat_status_not_heard">Not Heard</string>
|
||||
<string name="amsat_status_crew_active">Crew Active</string>
|
||||
<string name="amsat_upload">Upload</string>
|
||||
<string name="amsat_upload_back">Back</string>
|
||||
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||
<string name="amsat_upload_status">Status</string>
|
||||
<string name="amsat_upload_callsign">Callsign</string>
|
||||
<string name="amsat_upload_grid">Grid</string>
|
||||
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||
<string name="amsat_upload_confirm">Upload</string>
|
||||
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||
<string name="amsat_upload_grid_none">Not provided</string>
|
||||
<string name="amsat_upload_success">Report uploaded</string>
|
||||
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
|
||||
<string name="amsat_upload_active">Active</string>
|
||||
<string name="amsat_upload_tlm">TLM/Beacon</string>
|
||||
<string name="amsat_upload_not_heard">Not Heard</string>
|
||||
<string name="amsat_upload_crew_active">Crew Active</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -134,24 +111,6 @@
|
||||
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||
|
||||
<!-- Radar screen (CAT control / SSTV) -->
|
||||
<string name="radar_connect">Connect</string>
|
||||
<string name="radar_disconnect">Disconnect</string>
|
||||
<string name="radar_off">Off</string>
|
||||
<string name="radar_stop">Stop</string>
|
||||
<string name="radar_track">Track</string>
|
||||
<string name="radar_tx_base">TX Base: </string>
|
||||
|
||||
<!-- SSTV screen -->
|
||||
<string name="sstv_grant_permission">Grant permission</string>
|
||||
<string name="sstv_listening">Listening…</string>
|
||||
<string name="sstv_mic_permission">Microphone access needed</string>
|
||||
<string name="sstv_mode_format">Mode: %s</string>
|
||||
<string name="sstv_mode_title">SSTV Mode</string>
|
||||
<string name="sstv_no_signal">No signal</string>
|
||||
<string name="sstv_no_transceiver">No transceiver selected</string>
|
||||
<string name="sstv_rx_format">RX: %s Hz</string>
|
||||
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Prev</string>
|
||||
<string name="map_next">Next</string>
|
||||
@@ -184,12 +143,6 @@
|
||||
<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="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_time">d MMM yyyy - HH:mm:ss</string>
|
||||
|
||||
@@ -218,12 +171,10 @@
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
|
||||
<string name="prefs_data_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_sources_title">Custom sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
@@ -247,8 +198,6 @@
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
|
||||
<string name="prefs_bt_title">Bluetooth output</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
@@ -286,7 +235,4 @@
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
|
||||
<!-- Main screen -->
|
||||
<string name="tracking_format">Tracking: %s</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,5 +1,8 @@
|
||||
* Tweaked sun calculations, added tests by atsunatsu (#241)
|
||||
* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||
* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||
* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||
* Added ARISS source, tweaked SSTV sensitivity #225
|
||||
* Implemented AOS window and elev filter #226
|
||||
* Added cleartext traffic support for TLEs #227
|
||||
* Fixed OMM import, tweaked error messages #228
|
||||
* Added support for Icom CAT by ruilvo #229
|
||||
* Added tweakable Radar offset by qwqtoday #230
|
||||
* Localized date/time formats by atsunatsu #231
|
||||
* Added linear radio Doppler calc by atsunatsu #232
|
||||
@@ -21,7 +21,6 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import kotlin.math.cos
|
||||
@@ -40,11 +39,9 @@ import kotlin.math.sin
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
* evaluations per row, which is imperceptible.
|
||||
*/
|
||||
class MapNightOverlay : Overlay() {
|
||||
|
||||
@@ -61,9 +58,6 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
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) {
|
||||
if (shadow) return
|
||||
|
||||
@@ -77,15 +71,6 @@ class MapNightOverlay : Overlay() {
|
||||
val h = mapView.height
|
||||
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,
|
||||
// then find the latitude range that is in night and shade it.
|
||||
// Since longitude is constant along a vertical strip and the day/night
|
||||
@@ -94,14 +79,22 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
var x = 0
|
||||
while (x < w) {
|
||||
val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
|
||||
// Get the geographic coordinate at the top and bottom of this column.
|
||||
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 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 = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
||||
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
|
||||
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
|
||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
||||
|
||||
when {
|
||||
dotTop < 0 && dotBot < 0 -> {
|
||||
@@ -149,7 +142,7 @@ class MapNightOverlay : Overlay() {
|
||||
var hi = yBot
|
||||
while (hi - lo > 1) {
|
||||
val mid = (lo + hi) / 2
|
||||
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
|
||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
||||
val latRad = Math.toRadians(geo.latitude)
|
||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||
if (dot < 0) hi = mid else lo = mid
|
||||
|
||||
@@ -62,6 +62,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
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.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
@@ -70,10 +71,6 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
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.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
@@ -129,21 +126,6 @@ fun MapDestination() {
|
||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -301,49 +283,19 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
private val markerPool = HashMap<String, Marker>()
|
||||
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(
|
||||
posMap: Map<OrbitalObject, GeoPos>,
|
||||
mapView: MapView,
|
||||
action: (OrbitalObject) -> Unit
|
||||
) {
|
||||
try {
|
||||
lastPositions = posMap
|
||||
lastAction = action
|
||||
// Clear caches when the MapView instance changes (e.g. config change)
|
||||
if (lastMapView !== mapView) {
|
||||
lastMapView = mapView
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
dotIcon = null
|
||||
footprintPolyline = 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
|
||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||
@@ -352,48 +304,17 @@ private fun setPositions(
|
||||
}
|
||||
folder.items.clear()
|
||||
|
||||
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
|
||||
// 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 activeNames = HashSet<String>(posMap.size)
|
||||
posMap.forEach { (satellite, geoPos) ->
|
||||
val name = satellite.data.name
|
||||
activeNames.add(name)
|
||||
val marker = markerPool.getOrPut(name) {
|
||||
Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
// 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
|
||||
}
|
||||
icon = getCachedTextIcon(name, mapView)
|
||||
}
|
||||
}
|
||||
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
|
||||
val pos = marker.position
|
||||
if (pos != null) {
|
||||
@@ -402,32 +323,22 @@ private fun setPositions(
|
||||
} else {
|
||||
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||
}
|
||||
marker.setOnMarkerClickListener { _, _ ->
|
||||
action(satellite)
|
||||
true
|
||||
}
|
||||
folder.add(marker)
|
||||
}
|
||||
// Evict markers that are no longer tracked or no longer visible
|
||||
markerPool.keys.retainAll(activeNames)
|
||||
visibleSats.clear()
|
||||
// Evict markers for satellites no longer tracked
|
||||
val iter = markerPool.keys.iterator()
|
||||
while (iter.hasNext()) {
|
||||
if (iter.next() !in activeNames) iter.remove()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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 {
|
||||
iconCache[name]?.let { return it }
|
||||
val labelRect = Rect()
|
||||
@@ -465,8 +376,9 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
||||
private var footprintPolyline: Polyline? = null
|
||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
||||
|
||||
private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
|
||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
||||
try {
|
||||
val rangeCircle = orbitalPos.getRangeCircle()
|
||||
var pts = footprintPoints
|
||||
if (pts == null || pts.size != rangeCircle.size) {
|
||||
pts = ArrayList(rangeCircle.size)
|
||||
@@ -596,28 +508,9 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
||||
lifecycle.addObserver(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
|
||||
}
|
||||
|
||||
private fun clearMapCaches() {
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
dotIcon = null
|
||||
footprintPolyline = null
|
||||
footprintPoints = null
|
||||
lastPositions = null
|
||||
lastAction = null
|
||||
lastMapView = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||
LifecycleEventObserver { _, event ->
|
||||
|
||||
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.feature.map
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
|
||||
data class MapState(
|
||||
val mapData: MapData? = null,
|
||||
@@ -28,7 +29,7 @@ data class MapState(
|
||||
val stationPosition: GeoPos? = null,
|
||||
val orbitalPass: OrbitalPass,
|
||||
val track: List<List<GeoPos>>? = null,
|
||||
val footprint: List<GeoPos>? = null,
|
||||
val footprint: OrbitalPos? = null,
|
||||
val positions: Map<OrbitalObject, GeoPos>? = null,
|
||||
val sunLatDeg: Double = 0.0,
|
||||
val sunLonDeg: Double = 0.0,
|
||||
@@ -41,7 +42,6 @@ sealed interface MapAction {
|
||||
data object SelectNext : MapAction
|
||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||
}
|
||||
|
||||
data class MapData(
|
||||
|
||||
@@ -36,7 +36,6 @@ import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -46,12 +45,10 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class MapViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
@@ -72,9 +69,6 @@ class MapViewModel(
|
||||
private var dataUpdateJob: Job? = null
|
||||
private var dataUpdateRate = 1000L
|
||||
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
|
||||
|
||||
init {
|
||||
@@ -93,7 +87,6 @@ class MapViewModel(
|
||||
MapAction.SelectNext -> scrollSelection(false)
|
||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +124,7 @@ class MapViewModel(
|
||||
selectedOrbitalObject = orbitalObject
|
||||
viewModelScope.launch {
|
||||
dataUpdateJob?.cancelAndJoin()
|
||||
// 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) {
|
||||
dataUpdateJob = launch {
|
||||
val dateNow = Date()
|
||||
getStationPosition()
|
||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||
@@ -146,11 +135,9 @@ class MapViewModel(
|
||||
else -> updateFreq
|
||||
}
|
||||
while (isActive) {
|
||||
// Suspends while the map is off-screen instead of predicting into the void
|
||||
isScreenVisible.first { it }
|
||||
dateNow.time = System.currentTimeMillis()
|
||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||
delay(effectiveRate.milliseconds)
|
||||
delay(effectiveRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,8 +187,7 @@ class MapViewModel(
|
||||
|
||||
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
||||
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
||||
// Range circle is 721 trig-heavy points — keep it on this background dispatcher
|
||||
val footprint = satPos.getRangeCircle()
|
||||
val footprint = satPos
|
||||
val mapData = buildMapData(selected, satPos, date)
|
||||
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
||||
@@ -308,7 +294,7 @@ class MapViewModel(
|
||||
|
||||
companion object {
|
||||
/** Number of parallel chunks for satellite position computation */
|
||||
private val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
|
||||
private const val PARALLEL_CHUNKS = 4
|
||||
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
|
||||
@@ -58,7 +58,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||
@@ -140,7 +140,7 @@ internal fun PassesFilterDialog(
|
||||
)
|
||||
cancel()
|
||||
}
|
||||
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||
SliderRow(
|
||||
title = stringResource(R.string.pass_filter_elev),
|
||||
value = elevationValueNew.doubleValue,
|
||||
@@ -365,7 +365,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||
}
|
||||
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
||||
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(240.dp),
|
||||
modifier = Modifier
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -71,10 +72,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
@@ -135,7 +136,20 @@ private fun PassesScreen(
|
||||
}
|
||||
if (uiState.shouldSeeWhatsNew) {
|
||||
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
||||
WhatsNewDialog(onDismiss = dismiss)
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.pass_whatsnew_title),
|
||||
onDismissRequest = dismiss,
|
||||
onAccept = dismiss,
|
||||
titleFontSize = 18
|
||||
) { padding ->
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
ScreenColumn(
|
||||
topBar = { isVerticalLayout ->
|
||||
|
||||
@@ -31,12 +31,9 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
@@ -118,7 +115,6 @@ private fun RadarScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layoutPadding()
|
||||
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
|
||||
.keepScreenOn(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
|
||||
@@ -49,15 +49,14 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -79,12 +78,12 @@ internal fun SstvPage(
|
||||
) {
|
||||
Text(text = "🎙️", fontSize = 48.sp)
|
||||
Text(
|
||||
text = stringResource(R.string.sstv_mic_permission),
|
||||
text = "Microphone access needed",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Button(onClick = requestMicPermission) {
|
||||
Text(stringResource(R.string.sstv_grant_permission))
|
||||
Text("Grant permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,14 +96,14 @@ internal fun SstvPage(
|
||||
if (showModeDialog.value) {
|
||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||
val dismiss = { showModeDialog.value = false }
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.sstv_mode_title),
|
||||
onAccept = dismiss,
|
||||
onCancel = dismiss
|
||||
) {
|
||||
SharedDialog(
|
||||
title = "SSTV Mode",
|
||||
onDismissRequest = dismiss,
|
||||
onAccept = dismiss
|
||||
) { _ ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.69f)
|
||||
.fillMaxHeight(0.7f)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
@@ -168,7 +167,7 @@ internal fun SstvPage(
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = if (sstv.status == SstvStatus.Recording) stringResource(R.string.sstv_listening) else stringResource(R.string.sstv_no_signal),
|
||||
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
@@ -197,7 +196,7 @@ internal fun SstvPage(
|
||||
) {
|
||||
// Doppler-corrected downlink frequency hint
|
||||
OutlinedText(
|
||||
text = dopplerFrequency?.let { stringResource(R.string.sstv_rx_format, it) } ?: stringResource(R.string.sstv_no_transceiver),
|
||||
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fillColor = MaterialTheme.colorScheme.primary,
|
||||
@@ -228,7 +227,7 @@ internal fun SstvPage(
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(R.string.sstv_mode_format, sstv.selectedMode),
|
||||
text = "Mode: ${sstv.selectedMode}",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
|
||||
@@ -403,7 +403,7 @@ private fun ExpandedRadioControl(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.radar_tx_base),
|
||||
text = "TX Base: ",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -460,7 +460,7 @@ private fun ExpandedRadioControl(
|
||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.radar_off),
|
||||
text = "Off",
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -492,19 +492,19 @@ private fun ExpandedRadioControl(
|
||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||
text = stringResource(R.string.radar_connect),
|
||||
text = "Connect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||
text = stringResource(R.string.radar_disconnect),
|
||||
text = "Disconnect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||
text = if (radioControl.isTracking) stringResource(R.string.radar_stop) else stringResource(R.string.radar_track),
|
||||
text = if (radioControl.isTracking) "Stop" else "Track",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
@@ -726,7 +726,7 @@ private fun DopplerFrequencyCalculator(
|
||||
decorationBox = { innerTextField ->
|
||||
if (offsetKHz.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.radar_doppler_offset_hint),
|
||||
text = "Offset (kHz)",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
+2
-2
@@ -39,9 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@@ -59,7 +59,7 @@ internal fun MultiModesDialog(
|
||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||
}
|
||||
val onAccept = { accept(selected.value.toList()) }
|
||||
ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(240.dp),
|
||||
modifier = Modifier
|
||||
|
||||
+7
-7
@@ -62,11 +62,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
@@ -97,16 +96,17 @@ private fun SatellitesScreen(
|
||||
}
|
||||
if (uiState.shouldSeeWarning) {
|
||||
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
||||
InfoDialog(
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.sat_warning_title),
|
||||
onDismiss = dismiss,
|
||||
onAccept = dismiss
|
||||
) {
|
||||
onDismissRequest = dismiss,
|
||||
onAccept = dismiss,
|
||||
titleFontSize = 18
|
||||
) { padding ->
|
||||
Text(
|
||||
text = stringResource(R.string.sat_warning_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
|
||||
+107
-304
@@ -17,44 +17,26 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -63,20 +45,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.DragReorderState
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
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
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@@ -90,7 +63,7 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
|
||||
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
|
||||
val titleText = stringResource(id = R.string.prefs_station_title)
|
||||
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
||||
ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
||||
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
@@ -134,7 +107,7 @@ private fun LocatorDialogPreview() {
|
||||
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
||||
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
||||
val onAccept = { save(locator.value).also { dismiss() } }
|
||||
ConfirmDialog(
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_locator_title),
|
||||
onCancel = dismiss,
|
||||
onAccept = onAccept
|
||||
@@ -156,19 +129,11 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
|
||||
private fun TransceiversDialogPreview() {
|
||||
MainTheme {
|
||||
DataSourcesDialog(
|
||||
satelliteUrls = listOf(
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"amsat.org/tle/current/nasabare.txt"
|
||||
),
|
||||
transceiversUrls = listOf(
|
||||
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
),
|
||||
satelliteEnabled = listOf(true, false),
|
||||
transceiversEnabled = listOf(true),
|
||||
statusCodes = mapOf(
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
|
||||
"amsat.org/tle/current/nasabare.txt" to 404
|
||||
),
|
||||
useCustomTle = true,
|
||||
useCustomTransceivers = true,
|
||||
tleUrl = "https://example.com/tle.txt",
|
||||
transceiversUrl = "https://example.com/tx.json",
|
||||
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
|
||||
onImportTle = {},
|
||||
onImportTransceivers = {},
|
||||
onDismiss = {},
|
||||
@@ -177,256 +142,120 @@ private fun TransceiversDialogPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for one data-source row: url, enabled flag and stable id all live
|
||||
* together so a reorder, a toggle or an edit is a single list mutation instead of several parallel
|
||||
* lists/maps having to stay in sync (which used to fan out into extra recompositions).
|
||||
*/
|
||||
private data class SourceEntry(val id: Long, val url: String, val enabled: Boolean = true)
|
||||
|
||||
@Composable
|
||||
fun DataSourcesDialog(
|
||||
satelliteUrls: List<String>,
|
||||
transceiversUrls: List<String>,
|
||||
satelliteEnabled: List<Boolean>,
|
||||
transceiversEnabled: List<Boolean>,
|
||||
statusCodes: Map<String, Int>,
|
||||
useCustomTle: Boolean,
|
||||
useCustomTransceivers: Boolean,
|
||||
tleUrl: String,
|
||||
transceiversUrl: String,
|
||||
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
|
||||
onImportTle: () -> Unit,
|
||||
onImportTransceivers: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
|
||||
onSave: (Boolean, Boolean, String, String) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
|
||||
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
||||
val satEntries = remember {
|
||||
satelliteUrls.mapIndexed { i, url ->
|
||||
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
|
||||
}.toMutableStateList()
|
||||
}
|
||||
val txEntries = remember {
|
||||
transceiversUrls.mapIndexed { i, url ->
|
||||
SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true })
|
||||
}.toMutableStateList()
|
||||
}
|
||||
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 isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
|
||||
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
|
||||
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
||||
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
||||
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()
|
||||
}
|
||||
onSave(
|
||||
isEnabledCustomTle.value,
|
||||
isEnabledCustomTransceivers.value,
|
||||
urlTle.value,
|
||||
urlTransceivers.value
|
||||
)
|
||||
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(
|
||||
val onCancel = { onDismiss() }
|
||||
SharedDialog(
|
||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
||||
onCancel = onDismiss,
|
||||
onCancel = onCancel,
|
||||
onAccept = onAccept,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.84f)
|
||||
.padding(horizontal = padding),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = PaddingValues(vertical = 0.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CardButton(
|
||||
onClick = { onImportTle(); onDismiss() },
|
||||
text = "TLE/3LE (.txt)\nOMM (.csv)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(
|
||||
onClick = { onImportTransceivers(); onDismiss() },
|
||||
text = "Transceivers\nSatNOGS (.json)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
sourceSection(
|
||||
sectionKey = "sat",
|
||||
label = satTitle,
|
||||
hint = satHint,
|
||||
entries = satEntries,
|
||||
dragState = dragState,
|
||||
statusCodes = statusCodes,
|
||||
onAdd = { satEntries.add(SourceEntry(nextId.longValue++, "")) },
|
||||
onRestore = onRestoreSatDefaults,
|
||||
onMove = { from, to -> satEntries.add(to, satEntries.removeAt(from)) },
|
||||
onRemove = { i -> satEntries.removeAt(i) },
|
||||
onToggle = { i -> satEntries[i] = satEntries[i].copy(enabled = !satEntries[i].enabled) },
|
||||
onUrlChange = { i, v -> satEntries[i] = satEntries[i].copy(url = v) }
|
||||
)
|
||||
sourceSection(
|
||||
sectionKey = "tx",
|
||||
label = transceiversTitle,
|
||||
hint = transceiversHint,
|
||||
entries = txEntries,
|
||||
dragState = dragState,
|
||||
statusCodes = statusCodes,
|
||||
onAdd = { txEntries.add(SourceEntry(nextId.longValue++, "")) },
|
||||
onRestore = onRestoreTxDefaults,
|
||||
onMove = { from, to -> txEntries.add(to, txEntries.removeAt(from)) },
|
||||
onRemove = { i -> txEntries.removeAt(i) },
|
||||
onToggle = { i -> txEntries[i] = txEntries[i].copy(enabled = !txEntries[i].enabled) },
|
||||
onUrlChange = { i, v -> txEntries[i] = txEntries[i].copy(url = v) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.sourceSection(
|
||||
sectionKey: String,
|
||||
label: String,
|
||||
hint: String,
|
||||
entries: SnapshotStateList<SourceEntry>,
|
||||
dragState: DragReorderState,
|
||||
statusCodes: Map<String, Int>,
|
||||
onAdd: () -> Unit,
|
||||
onRestore: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
onToggle: (Int) -> Unit,
|
||||
onUrlChange: (Int, String) -> Unit
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconCard(
|
||||
action = onRestore,
|
||||
resId = R.drawable.ic_restore,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
IconCard(
|
||||
action = onAdd,
|
||||
resId = R.drawable.ic_add,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = hint,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
itemsIndexed(entries, key = { _, entry -> "$sectionKey-${entry.id}" }) { index, entry ->
|
||||
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val rowState = rememberDragRowState(
|
||||
dragState = dragState,
|
||||
items = entries,
|
||||
item = entry,
|
||||
key = { "$sectionKey-${it.id}" },
|
||||
onMove = onMove
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.dragLift(isLifted = rowState.isLifted, translationY = rowState.translationY)
|
||||
.animateItem(
|
||||
fadeInSpec = tween(durationMillis = 200),
|
||||
// The dragged row repositions instantly, while its neighbors smoothly
|
||||
// slide out of the way (the "squeeze" effect).
|
||||
placementSpec = if (rowState.isDragging) {
|
||||
tween(durationMillis = 0)
|
||||
} else {
|
||||
tween(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
CardButton(
|
||||
onClick = {
|
||||
onImportTle()
|
||||
onDismiss()
|
||||
},
|
||||
fadeOutSpec = tween(durationMillis = 200)
|
||||
text = "TLE/3LE (.txt)\nOMM (.csv)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
) {
|
||||
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)
|
||||
CardButton(
|
||||
onClick = {
|
||||
onImportTransceivers()
|
||||
onDismiss()
|
||||
},
|
||||
text = "Transceivers\nSatNOGS (.json)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
|
||||
Switch(
|
||||
checked = isEnabledCustomTle.value,
|
||||
onCheckedChange = { enabled ->
|
||||
if (!enabled) {
|
||||
isEnabledCustomTle.value = false
|
||||
} else {
|
||||
requestCustomSourcesPermission(
|
||||
{ isEnabledCustomTle.value = true },
|
||||
{ isEnabledCustomTle.value = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { onRemove(index) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = urlTle.value,
|
||||
onValueChange = { urlTle.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
|
||||
singleLine = true,
|
||||
enabled = entry.enabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isEnabledCustomTle.value,
|
||||
)
|
||||
Checkbox(
|
||||
checked = entry.enabled,
|
||||
onCheckedChange = { onToggle(index) },
|
||||
modifier = Modifier.padding(top = textFieldLabelOffset)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
|
||||
Switch(
|
||||
checked = isEnabledCustomTransceivers.value,
|
||||
onCheckedChange = { enabled ->
|
||||
if (!enabled) {
|
||||
isEnabledCustomTransceivers.value = false
|
||||
} else {
|
||||
requestCustomSourcesPermission(
|
||||
{ isEnabledCustomTransceivers.value = true },
|
||||
{ isEnabledCustomTransceivers.value = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = urlTransceivers.value,
|
||||
onValueChange = { urlTransceivers.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isEnabledCustomTransceivers.value,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@Composable
|
||||
fun PreviewNetworkOutputDialog() {
|
||||
@@ -441,7 +270,6 @@ fun PreviewNetworkOutputDialog() {
|
||||
frequencyAddress = "127.0.0.1",
|
||||
frequencyPort = "4532",
|
||||
frequencyFormat = $$"F $FREQ",
|
||||
frequencyOffsetHz = 0L,
|
||||
bluetoothRotatorState = false,
|
||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||
bluetoothRotatorName = "Default",
|
||||
@@ -451,7 +279,7 @@ fun PreviewNetworkOutputDialog() {
|
||||
bluetoothFrequencyFormat = $$"F $FREQ"
|
||||
),
|
||||
onDismiss = {},
|
||||
onSave = { _, _, _, _, _, _, _, _, _ -> }
|
||||
onSave = { _, _, _, _, _, _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -462,7 +290,7 @@ fun NetworkOutputDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (
|
||||
Boolean, String, String, String,
|
||||
Boolean, String, String, String, Long
|
||||
Boolean, String, String, String
|
||||
) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
@@ -476,19 +304,16 @@ fun NetworkOutputDialog(
|
||||
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
|
||||
}
|
||||
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
|
||||
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
|
||||
val onAccept = {
|
||||
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
|
||||
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
|
||||
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
|
||||
onSave(
|
||||
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
|
||||
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
|
||||
frequencyState.value, freqIp, freqPort, frequencyFormat.value
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
ConfirmDialog(
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_net_title),
|
||||
onCancel = onDismiss,
|
||||
onAccept = onAccept
|
||||
@@ -517,27 +342,6 @@ fun NetworkOutputDialog(
|
||||
onFormatChange = { frequencyFormat.value = it },
|
||||
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = frequencyOffsetHz.value,
|
||||
onValueChange = { frequencyOffsetHz.value = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
|
||||
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { frequencyOffsetHz.value = "0" },
|
||||
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_close),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = frequencyState.value
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
@@ -566,7 +370,6 @@ fun PreviewBluetoothOutputDialog() {
|
||||
frequencyAddress = "127.0.0.1",
|
||||
frequencyPort = "4532",
|
||||
frequencyFormat = $$"F $FREQ",
|
||||
frequencyOffsetHz = 0L,
|
||||
bluetoothRotatorState = false,
|
||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||
bluetoothRotatorName = "Default",
|
||||
@@ -608,7 +411,7 @@ fun BluetoothOutputDialog(
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
ConfirmDialog(
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_bt_title),
|
||||
onCancel = onDismiss,
|
||||
onAccept = onAccept
|
||||
@@ -735,7 +538,7 @@ fun RadioControlDialog(
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
ConfirmDialog(
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.rc_settings_title),
|
||||
onCancel = onDismiss,
|
||||
onAccept = onAccept
|
||||
@@ -792,7 +595,7 @@ fun RadioControlDialog(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_split_mode),
|
||||
text = "Split mode (IC-705 only)",
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = splitModeLabelColor
|
||||
)
|
||||
@@ -832,7 +635,7 @@ fun RadioControlDialog(
|
||||
|
||||
if (selectingFor.value.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_paired_devices),
|
||||
text = "Paired Bluetooth Devices:",
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
+67
-36
@@ -64,7 +64,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
@@ -75,7 +74,6 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -94,10 +92,22 @@ fun SettingsDestination() {
|
||||
@Composable
|
||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||
val dialogs = rememberDialogVisibility()
|
||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val permissions = rememberSettingsPermissions(
|
||||
sendAction = onAction,
|
||||
onBluetoothGranted = { dialogs.bluetooth = true },
|
||||
onNetworkGranted = { dialogs.network = true }
|
||||
onNetworkGranted = { dialogs.network = true },
|
||||
onCustomSourcesPermissionGranted = {
|
||||
pendingCustomSourcesGrant.value?.invoke()
|
||||
pendingCustomSourcesGrant.value = null
|
||||
pendingCustomSourcesDeny.value = null
|
||||
},
|
||||
onCustomSourcesPermissionDenied = {
|
||||
pendingCustomSourcesDeny.value?.invoke()
|
||||
pendingCustomSourcesGrant.value = null
|
||||
pendingCustomSourcesDeny.value = null
|
||||
}
|
||||
)
|
||||
|
||||
// Dialogs
|
||||
@@ -118,23 +128,30 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
if (dialogs.dataSources) {
|
||||
DataSourcesDialog(
|
||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||
statusCodes = uiState.dataSourcesStatus,
|
||||
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
|
||||
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
||||
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
||||
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
||||
requestCustomSourcesPermission = { onGranted, onDenied ->
|
||||
pendingCustomSourcesGrant.value = onGranted
|
||||
pendingCustomSourcesDeny.value = onDenied
|
||||
permissions.launchCustomSourcesPermission()
|
||||
},
|
||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||
onDismiss = { dialogs.dataSources = false },
|
||||
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||
val newSettings = DataSourcesSettings(
|
||||
satelliteUrls = satUrls,
|
||||
transceiversUrls = txUrls,
|
||||
satelliteEnabled = satEnabled,
|
||||
transceiversEnabled = txEnabled
|
||||
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
|
||||
val current = uiState.dataSourcesSettings
|
||||
val newSettings = current.copy(
|
||||
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
|
||||
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
|
||||
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
|
||||
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
||||
)
|
||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||
onAction(SettingsAction.UpdateFromWeb)
|
||||
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
|
||||
onAction(SettingsAction.UpdateFromWeb)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -142,15 +159,14 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
NetworkOutputDialog(
|
||||
initialSettings = uiState.rcSettings,
|
||||
onDismiss = { dialogs.network = false },
|
||||
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
|
||||
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
|
||||
onAction(
|
||||
SettingsAction.UpdateRC(
|
||||
uiState.rcSettings.copy(
|
||||
rotatorState = rotState, rotatorAddress = rotAddr,
|
||||
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
||||
frequencyState = freqState, frequencyAddress = freqAddr,
|
||||
frequencyPort = freqPort, frequencyFormat = freqFmt,
|
||||
frequencyOffsetHz = freqOffsetHz
|
||||
frequencyPort = freqPort, frequencyFormat = freqFmt
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -182,13 +198,10 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||
)
|
||||
}
|
||||
if (dialogs.whatsNew) {
|
||||
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
|
||||
}
|
||||
|
||||
// URLs for top bar
|
||||
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 fdroidTitle = stringResource(R.string.prefs_fdroid_title)
|
||||
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
|
||||
@@ -205,11 +218,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
if (isVerticalLayout) {
|
||||
TopBar {
|
||||
TopCard(
|
||||
onClick = { dialogs.whatsNew = true },
|
||||
onClick = { safeOpenUri(appUrl) },
|
||||
version = uiState.appVersionName,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
||||
}
|
||||
TopBar {
|
||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -231,9 +244,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
} else {
|
||||
TopBar {
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
||||
TopCard(
|
||||
onClick = { dialogs.whatsNew = true },
|
||||
onClick = { safeOpenUri(appUrl) },
|
||||
version = uiState.appVersionName,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
@@ -342,9 +355,9 @@ private fun LocationCard(
|
||||
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude))
|
||||
Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude))
|
||||
Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator))
|
||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
@@ -654,7 +667,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
||||
.clickable { onClick() }) {
|
||||
Spacer(Modifier)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_sputnik),
|
||||
painter = painterResource(id = R.drawable.ic_satellites),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@@ -705,7 +718,6 @@ private class DialogVisibility {
|
||||
var network by mutableStateOf(false)
|
||||
var bluetooth by mutableStateOf(false)
|
||||
var radioControl by mutableStateOf(false)
|
||||
var whatsNew by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -713,12 +725,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
return rememberSaveable(saver = run {
|
||||
androidx.compose.runtime.saveable.Saver(
|
||||
save = {
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
|
||||
},
|
||||
restore = {
|
||||
DialogVisibility().apply {
|
||||
position = it[0]; locator = it[1]; dataSources = it[2]
|
||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
|
||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -735,14 +747,17 @@ private class SettingsPermissions(
|
||||
val launchTleImport: () -> Unit,
|
||||
val launchTransceiverImport: () -> Unit,
|
||||
val launchBluetooth: () -> Unit,
|
||||
val launchNetwork: () -> Unit
|
||||
val launchNetwork: () -> Unit,
|
||||
val launchCustomSourcesPermission: () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rememberSettingsPermissions(
|
||||
sendAction: (SettingsAction) -> Unit,
|
||||
onBluetoothGranted: () -> Unit,
|
||||
onNetworkGranted: () -> Unit
|
||||
onNetworkGranted: () -> Unit,
|
||||
onCustomSourcesPermissionGranted: () -> Unit,
|
||||
onCustomSourcesPermissionDenied: () -> Unit
|
||||
): SettingsPermissions {
|
||||
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
||||
val locationRequest = rememberLauncherForActivityResult(
|
||||
@@ -779,6 +794,15 @@ private fun rememberSettingsPermissions(
|
||||
else sendAction(SettingsAction.ShowToast(networkError))
|
||||
}
|
||||
|
||||
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
onCustomSourcesPermissionGranted()
|
||||
} else {
|
||||
onCustomSourcesPermissionDenied()
|
||||
sendAction(SettingsAction.ShowToast(networkError))
|
||||
}
|
||||
}
|
||||
|
||||
return remember {
|
||||
SettingsPermissions(
|
||||
launchLocation = {
|
||||
@@ -795,6 +819,13 @@ private fun rememberSettingsPermissions(
|
||||
} else {
|
||||
onNetworkGranted()
|
||||
}
|
||||
},
|
||||
launchCustomSourcesPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
} else {
|
||||
onCustomSourcesPermissionGranted()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ data class SettingsState(
|
||||
val rcSettings: RCSettings,
|
||||
val radioControlSettings: RadioControlSettings,
|
||||
val dataSourcesSettings: DataSourcesSettings,
|
||||
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
||||
)
|
||||
|
||||
|
||||
+1
-6
@@ -49,7 +49,6 @@ class SettingsViewModel(
|
||||
rcSettings = settingsRepo.rcSettings.value,
|
||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||
pairedBluetoothDevices = container.providePairedBluetoothDevices()
|
||||
)
|
||||
)
|
||||
@@ -93,11 +92,6 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.dataSourcesStatus.collect { status ->
|
||||
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.radioControlSettings.collect { settings ->
|
||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||
@@ -206,6 +200,7 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.featurePlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.status"
|
||||
compileOptions {
|
||||
encoding = "UTF-8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
}
|
||||
+149
-401
@@ -1,34 +1,37 @@
|
||||
package com.rtbishop.look4sat.feature.status
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -39,15 +42,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -56,32 +55,10 @@ import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import java.text.SimpleDateFormat
|
||||
import com.rtbishop.look4sat.feature.status.R as StatusR
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
||||
private val TILE_WIDTH: Dp = 64.dp
|
||||
|
||||
private data class UploadOption(
|
||||
val apiValue: String,
|
||||
val labelResId: Int,
|
||||
val color: Color
|
||||
)
|
||||
|
||||
private val UPLOAD_OPTIONS = listOf(
|
||||
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
|
||||
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
|
||||
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
|
||||
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
|
||||
)
|
||||
|
||||
/**
|
||||
* Map AMSAT status text to Material3 colorScheme colors.
|
||||
@@ -91,65 +68,37 @@ private val UPLOAD_OPTIONS = listOf(
|
||||
private fun statusColorOf(statusText: String): Color {
|
||||
return when {
|
||||
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
MaterialTheme.colorScheme.tertiary // Active
|
||||
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
MaterialTheme.colorScheme.tertiaryContainer // Telemetry
|
||||
statusText.contains("Not Heard", ignoreCase = true) ->
|
||||
Color(0xFFDC267F)
|
||||
Color(0xFFDC267F) // NotHeard pink (no semantic slot)
|
||||
else ->
|
||||
MaterialTheme.colorScheme.error
|
||||
MaterialTheme.colorScheme.error // Conflict
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
|
||||
|
||||
@Composable
|
||||
fun SatStatusDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
fun SatStatusScreen(container: IMainContainer) {
|
||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SatStatusScreen(
|
||||
uiState = uiState,
|
||||
refresh = viewModel::refresh,
|
||||
onToggleUpload = viewModel::toggleUploadPanel,
|
||||
onUploadReportChange = viewModel::setUploadReport,
|
||||
onUploadCallsignChange = viewModel::setUploadCallsign,
|
||||
onUploadGridChange = viewModel::setUploadGrid,
|
||||
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
|
||||
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
|
||||
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
|
||||
onDismissUpload = viewModel::collapseUploadPanel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SatStatusScreen(
|
||||
uiState: SatStatusUiState,
|
||||
refresh: () -> Unit,
|
||||
onToggleUpload: () -> Unit,
|
||||
onUploadReportChange: (String) -> Unit,
|
||||
onUploadCallsignChange: (String) -> Unit,
|
||||
onUploadGridChange: (String) -> Unit,
|
||||
onRequestSubmitUpload: () -> Unit,
|
||||
onConfirmSubmitUpload: (String) -> Unit,
|
||||
onDismissSubmitConfirmation: () -> Unit,
|
||||
onDismissUpload: () -> Unit
|
||||
) {
|
||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
||||
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
// Top: legend + refresh
|
||||
StatusHeader(
|
||||
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
onRefresh = { viewModel.refresh() }
|
||||
)
|
||||
LegendRow()
|
||||
|
||||
when {
|
||||
@@ -164,16 +113,29 @@ private fun SatStatusScreen(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
|
||||
Text(
|
||||
text = stringResource(id = R.string.amsat_load_failed),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
TextButton(onClick = { viewModel.refresh() }) {
|
||||
Text(text = stringResource(id = R.string.amsat_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Header
|
||||
HeaderRow(statuses = uiState.statuses)
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
// Row
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
items(uiState.statuses, key = { it.name }) { status ->
|
||||
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
|
||||
StatusRow(
|
||||
status = status,
|
||||
onClickDay = { day -> selectedDay = status to day }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,53 +147,52 @@ private fun SatStatusScreen(
|
||||
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()
|
||||
}
|
||||
onDismiss = { selectedDay = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Top: update time + refresh button (spinner while loading) */
|
||||
@Composable
|
||||
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
|
||||
private fun StatusHeader(
|
||||
fetchedAtUtcMs: Long,
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
|
||||
val angle by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
|
||||
label = "angle"
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (fetchedAtUtcMs > 0)
|
||||
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
|
||||
else
|
||||
stringResource(id = R.string.amsat_title),
|
||||
fontSize = 14.sp,
|
||||
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onRefresh),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_refresh),
|
||||
painter = painterResource(id = StatusR.drawable.ic_refresh),
|
||||
contentDescription = stringResource(id = R.string.amsat_refresh),
|
||||
modifier = Modifier.size(16.dp),
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.rotate(angle),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
@@ -239,384 +200,171 @@ private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh:
|
||||
}
|
||||
}
|
||||
|
||||
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
|
||||
/** Legend (official four colors) */
|
||||
@Composable
|
||||
private fun LegendRow() {
|
||||
val legend = listOf(
|
||||
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
|
||||
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
|
||||
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary,
|
||||
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
|
||||
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
||||
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
|
||||
stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
legend.forEach { (label, color) ->
|
||||
val alphaColor = color.copy(alpha = 0.25f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(alphaColor)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.size(10.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(color)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(3.dp))
|
||||
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
|
||||
/** Header: satellite name + 6 day dates */
|
||||
@Composable
|
||||
private fun HeaderRow(statuses: List<SatStatus>) {
|
||||
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.amsat_name),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f).padding(start = 4.dp)
|
||||
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
||||
)
|
||||
dates.forEach { date ->
|
||||
Text(
|
||||
text = date,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(TILE_WIDTH)
|
||||
modifier = Modifier.weight(0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
|
||||
/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
|
||||
@Composable
|
||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = status.name,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
||||
)
|
||||
status.days.forEach { day ->
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
DayCell(
|
||||
slot = slot,
|
||||
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
|
||||
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
|
||||
onClick = { onClickDay(day) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Day block: newest reported status among the day's 12 slots; gray when none. */
|
||||
/** Day block: newest reported status among the day's 12 slots; gray when none */
|
||||
@Composable
|
||||
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
val color = Color(slot.statusColor)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
.height(24.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (slot.count > 0) {
|
||||
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(
|
||||
text = slot.count.toString(),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Report detail dialog (callsign / date / time / grid) + AMSAT report upload panel. */
|
||||
/** Report detail dialog (3 levels: callsign/date/time/grid) */
|
||||
@Composable
|
||||
private fun ReportDialog(
|
||||
statusName: String,
|
||||
day: SatDay,
|
||||
reports: Map<String, SatReport>,
|
||||
upload: AmSatUploadUiState,
|
||||
onToggleUpload: () -> Unit,
|
||||
onReportChange: (String) -> Unit,
|
||||
onCallsignChange: (String) -> Unit,
|
||||
onGridChange: (String) -> Unit,
|
||||
onRequestSubmitReport: () -> Unit,
|
||||
onConfirmSubmitReport: () -> Unit,
|
||||
onDismissSubmitConfirmation: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||
InfoDialog(
|
||||
title = "$statusName · ${day.dateLabel}",
|
||||
onDismiss = onDismiss,
|
||||
onAccept = onDismiss,
|
||||
extraAction = {
|
||||
CardButton(
|
||||
onClick = onToggleUpload,
|
||||
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||
)
|
||||
}
|
||||
) {
|
||||
AnimatedVisibility(visible = upload.isExpanded) {
|
||||
AmSatUploadPanel(
|
||||
statusName = statusName,
|
||||
upload = upload,
|
||||
onReportChange = onReportChange,
|
||||
onCallsignChange = onCallsignChange,
|
||||
onGridChange = onGridChange,
|
||||
onRequestSubmitReport = onRequestSubmitReport
|
||||
)
|
||||
}
|
||||
if (upload.isConfirmingSubmit) {
|
||||
AmSatUploadConfirmDialog(
|
||||
statusName = statusName,
|
||||
upload = upload,
|
||||
onConfirm = onConfirmSubmitReport,
|
||||
onDismiss = onDismissSubmitConfirmation
|
||||
)
|
||||
}
|
||||
if (dayReports.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.amsat_no_reports),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
||||
items(dayReports) { report ->
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(statusColorOf(report.statusText))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = statusTextOf(report.statusText), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
||||
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmSatUploadPanel(
|
||||
statusName: String,
|
||||
upload: AmSatUploadUiState,
|
||||
onReportChange: (String) -> Unit,
|
||||
onCallsignChange: (String) -> Unit,
|
||||
onGridChange: (String) -> Unit,
|
||||
onRequestSubmitReport: () -> Unit
|
||||
) {
|
||||
val options = remember(statusName) {
|
||||
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_report),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_status),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
options.forEach { option ->
|
||||
val selected = upload.selectedReport == option.apiValue
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onReportChange(option.apiValue) },
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) Color.White else option.color)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(option.labelResId), maxLines = 1)
|
||||
}
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = option.color.copy(alpha = 0.10f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
selectedContainerColor = option.color,
|
||||
selectedLabelColor = Color.White
|
||||
),
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
enabled = true,
|
||||
selected = selected,
|
||||
borderColor = option.color.copy(alpha = 0.60f),
|
||||
selectedBorderColor = option.color
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = upload.callsign,
|
||||
onValueChange = onCallsignChange,
|
||||
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = upload.gridSquare,
|
||||
onValueChange = onGridChange,
|
||||
label = { Text(stringResource(R.string.amsat_upload_grid)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onRequestSubmitReport,
|
||||
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (upload.isSubmitting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(stringResource(R.string.amsat_upload_submit))
|
||||
}
|
||||
}
|
||||
upload.message?.let { message ->
|
||||
if (message == UPLOAD_MESSAGE_SUCCESS) {
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_success),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
upload.error?.let { error ->
|
||||
Text(
|
||||
text = uploadErrorText(error),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmSatUploadConfirmDialog(
|
||||
statusName: String,
|
||||
upload: AmSatUploadUiState,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
|
||||
val statusLabel = stringResource(option.labelResId)
|
||||
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||
val dayReports = day.slots.flatMap { it.reportIds }
|
||||
.mapNotNull { reports[it] }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||
title = { Text(text = "$statusName · ${day.dateLabel}") },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.amsat_upload_confirm_message,
|
||||
statusName,
|
||||
statusLabel,
|
||||
upload.callsign,
|
||||
grid
|
||||
)
|
||||
)
|
||||
if (dayReports.isEmpty()) {
|
||||
Text(stringResource(id = R.string.amsat_no_reports))
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.height(320.dp)) {
|
||||
items(dayReports) { report ->
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(statusColorOf(report.statusText))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = report.statusText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
||||
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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))
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun uploadErrorText(error: String): String {
|
||||
return when (error) {
|
||||
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
|
||||
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
|
||||
else -> stringResource(R.string.amsat_upload_failed, error)
|
||||
}
|
||||
}
|
||||
|
||||
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
|
||||
private fun formatFetchedAt(utcMs: Long): String {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = utcMs
|
||||
val day = cal.get(Calendar.DAY_OF_MONTH)
|
||||
val monthIndex = cal.get(Calendar.MONTH)
|
||||
val year = cal.get(Calendar.YEAR)
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
|
||||
} else {
|
||||
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUtcNowForUpload(utcMs: Long): String {
|
||||
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
return formatter.format(Date(utcMs))
|
||||
return "%02d-%02d %02d:%02d".format(
|
||||
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||
)
|
||||
}
|
||||
+22
-219
@@ -4,38 +4,13 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
const val AMSAT_REPORT_HEARD = "Heard"
|
||||
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
|
||||
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
|
||||
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
|
||||
|
||||
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
|
||||
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
|
||||
const val UPLOAD_MESSAGE_SUCCESS = "success"
|
||||
|
||||
data class AmSatUploadUiState(
|
||||
val isExpanded: Boolean = false,
|
||||
val selectedReport: String = AMSAT_REPORT_HEARD,
|
||||
val callsign: String = "",
|
||||
val gridSquare: String = "",
|
||||
val isSubmitting: Boolean = false,
|
||||
val isConfirmingSubmit: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class SatStatusUiState(
|
||||
val isLoading: Boolean = false,
|
||||
@@ -43,13 +18,11 @@ data class SatStatusUiState(
|
||||
val statuses: List<SatStatus> = emptyList(),
|
||||
val reports: Map<String, SatReport> = emptyMap(),
|
||||
val fetchedAtUtcMs: Long = 0L,
|
||||
val error: String? = null,
|
||||
val upload: AmSatUploadUiState = AmSatUploadUiState()
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class SatStatusViewModel(
|
||||
private val amSatRepo: IAmSatRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
private val container: IMainContainer
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
||||
@@ -63,9 +36,17 @@ class SatStatusViewModel(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val page = amSatRepo.fetchStatus()
|
||||
val page = container.amSatRepo.fetchStatus()
|
||||
if (page != null && page.statuses.isNotEmpty()) {
|
||||
applyStatusPage(page)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
statuses = page.statuses,
|
||||
reports = page.reports,
|
||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||
@@ -84,9 +65,16 @@ class SatStatusViewModel(
|
||||
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val page = amSatRepo.fetchStatus()
|
||||
val page = container.amSatRepo.fetchStatus()
|
||||
if (page != null && page.statuses.isNotEmpty()) {
|
||||
applyStatusPage(page)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRefreshing = false,
|
||||
statuses = page.statuses,
|
||||
reports = page.reports,
|
||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||
}
|
||||
@@ -96,196 +84,11 @@ 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 {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
|
||||
SatStatusViewModel(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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})?$")
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
android:viewportHeight="24"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "447"
|
||||
appVersionCode = "445"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7"
|
||||
appVersionName = "4.4.5"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -12,25 +12,24 @@ jdkVersion = "21"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
packageName = "com.rtbishop.look4sat"
|
||||
|
||||
android-gradle-plugin = "9.4.0"
|
||||
android-gradle-plugin = "9.3.1"
|
||||
|
||||
androidx-core-ktx = "1.19.0"
|
||||
androidx-core-splashscreen = "1.2.0"
|
||||
androidx-room = "2.8.5"
|
||||
androidx-room = "2.8.4"
|
||||
|
||||
compose-bom = "2026.09.00"
|
||||
compose-bom = "2026.06.01"
|
||||
compose-activity = "1.13.0"
|
||||
compose-lifecycle = "2.11.0"
|
||||
compose-navigation3 = "1.1.7"
|
||||
compose-navigation3 = "1.1.5"
|
||||
|
||||
google-ksp = "2.3.11"
|
||||
google-ksp = "2.3.10"
|
||||
|
||||
kotlin = "2.4.20"
|
||||
kotlin = "2.4.10"
|
||||
kotlin-coroutines = "1.11.0"
|
||||
kotlin-serialization = "1.11.0"
|
||||
|
||||
other-detekt = "1.23.8"
|
||||
other-okhttp = "5.5.0"
|
||||
other-okhttp = "5.4.0"
|
||||
other-osmdroid = "6.1.20"
|
||||
|
||||
test-junit4 = "4.13.2"
|
||||
@@ -73,8 +72,6 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
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
|
||||
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -93,7 +90,6 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
|
||||
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", 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
|
||||
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
|
||||
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
#Sat Sep 12 11:29:59 BST 2026
|
||||
#Sat Jun 27 12:51:12 BST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in new issue
Block a user