Compare commits

..
Author SHA1 Message Date
atsunatsu 8297500861 i18n: localize AMSAT status screen for Chinese 2026-09-01 16:39:25 +08:00
56 changed files with 657 additions and 3173 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -68,7 +68,6 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.ElevationThresholds
@@ -99,43 +98,29 @@ fun NavRoot(deeplink: String? = null) {
// Reverse: outgoing slides out to the right, incoming drifts in from the left // Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it } slideOutHorizontally(tween(300)) { it }
// Elevation color thresholds must be provided at the root level so that BOTH NavDisplay(
// the tab content (MainScreen) and the root-level RadarDestination see the modifier = Modifier.fillMaxSize(),
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen backStack = rootBackStack,
// in this NavDisplay, so a provider inside MainScreen never reaches it. onBack = navigateBack,
val context = LocalContext.current transitionSpec = { pushTransition },
val container = (context.applicationContext as IContainerProvider).getMainContainer() popTransitionSpec = { popTransition },
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() predictivePopTransitionSpec = { popTransition },
CompositionLocalProvider( entryDecorators = listOf(
LocalElevationThresholds provides ElevationThresholds( rememberSaveableStateHolderNavEntryDecorator(),
low = otherSettings.lowElevation, rememberViewModelStoreNavEntryDecorator()
high = otherSettings.highElevation ),
) entryProvider = entryProvider {
) { entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
NavDisplay( entry<RadarDestination> {
modifier = Modifier.fillMaxSize(), Surface(
backStack = rootBackStack, modifier = Modifier.fillMaxSize(),
onBack = navigateBack, color = MaterialTheme.colorScheme.background
transitionSpec = { pushTransition }, ) {
popTransitionSpec = { popTransition }, RadarDestination(navigateUp = navigateBack)
predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
entry<RadarDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
}
} }
} }
) }
} )
} }
@Composable @Composable
@@ -143,6 +128,7 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(Screen.Passes) val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull() val currentKey = backStack.lastOrNull()
@@ -152,81 +138,88 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val navItems = val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings) listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
NavigationSuiteScaffold( CompositionLocalProvider(
navigationSuiteItems = { LocalElevationThresholds provides ElevationThresholds(
navItems.forEach { screen -> low = otherSettings.lowElevation,
val isSelected = when (currentKey) { high = otherSettings.highElevation
is Screen.Satellites -> screen is Screen.Satellites )
is Screen.Passes -> screen is Screen.Passes
is Screen.Status -> screen is Screen.Status
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
item(
icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
}
},
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
),
layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) { ) {
Column { NavigationSuiteScaffold(
NavDisplay( navigationSuiteItems = {
backStack = backStack, navItems.forEach { screen ->
modifier = Modifier.weight(1f), val isSelected = when (currentKey) {
onBack = navigateBack, is Screen.Satellites -> screen is Screen.Satellites
transitionSpec = { fadeTransition }, is Screen.Passes -> screen is Screen.Passes
popTransitionSpec = { fadeTransition }, is Screen.Status -> screen is Screen.Status
predictivePopTransitionSpec = { fadeTransition }, is Screen.Map -> screen is Screen.Map
entryDecorators = listOf( is Screen.Settings -> screen is Screen.Settings
rememberSaveableStateHolderNavEntryDecorator(), else -> false
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
} }
entry<Screen.Passes> { item(
PassesDestination { catNum, aosTime -> icon = {
container.satelliteRepo.selectPass(catNum, aosTime) Icon(
navigateToRadar() painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
} }
} )
entry<Screen.Status> { SatStatusDestination() }
entry<Screen.Map> { MapDestination() }
entry<Screen.Settings> { SettingsDestination() }
} }
) },
if (trackingState.isActive) { navigationSuiteColors = NavigationSuiteDefaults.colors(
TrackingBanner( navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
state = trackingState, ),
onClick = { layoutType = when {
val pass = trackingState.currentPass !hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
if (pass != null) { !hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) else -> NavigationSuiteType.ShortNavigationBarMedium
navigateToRadar() }
) {
Column {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
} }
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
}
}
entry<Screen.Status> { SatStatusDestination() }
entry<Screen.Map> { MapDestination() }
entry<Screen.Settings> { SettingsDestination() }
} }
) )
if (trackingState.isActive) {
TrackingBanner(
state = trackingState,
onClick = {
val pass = trackingState.currentPass
if (pass != null) {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
navigateToRadar()
}
}
)
}
} }
} }
} }
@@ -259,7 +252,7 @@ private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(R.string.tracking_format, state.currentPass?.name ?: ""), text = "Tracking: ${state.currentPass?.name ?: ""}",
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
-40
View File
@@ -5,46 +5,6 @@ plugins {
alias(libs.plugins.google.ksp) apply false alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.other.detekt)
}
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
description = "Checks that source code satisfies detekt rules."
autoCorrect = false
}
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
description = "Applies code formatting rules to source code in-place."
autoCorrect = true
}
configure(listOf(detektCheck, detektApply)) {
configure {
group = "verification"
parallel = true
ignoreFailures = false
setSource(file(rootDir))
// Custom detekt config
config.setFrom("$projectDir/config/detekt/detekt.yml")
// Use default configuration on top of custom config
// (new detect rules will work out of the box after upgrading detekt version)
buildUponDefaultConfig = true
// Runs detekt for all files in the Gradle project and all subprojects without
// a need to configure detekt plugin in every subproject.
include("**/*.kt", "**/*.kts")
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
reports {
html.required.set(true)
xml.required.set(true)
}
}
dependencies {
detektPlugins(libs.other.detekt.formatting)
}
} }
tasks.register("clean", Delete::class.java) { tasks.register("clean", Delete::class.java) {
-808
View File
@@ -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.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@@ -40,18 +39,6 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)") @Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry> suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
@Query("SELECT catnum, epoch FROM entries")
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
@Query("SELECT catnum, name FROM entries")
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
suspend fun renameEntry(catnum: Int, name: String)
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
suspend fun deleteEntriesWithIds(ids: List<Int>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntries(entries: List<SatEntry>) suspend fun insertEntries(entries: List<SatEntry>)
@@ -78,7 +65,4 @@ interface Look4SatDao {
@Query("DELETE FROM radios") @Query("DELETE FROM radios")
suspend fun deleteRadios() suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
} }
@@ -19,23 +19,19 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio import com.rtbishop.look4sat.core.data.database.entity.SatRadio
const val DATABASE_NAME = "Look4SatDBv400" @Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() { abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao abstract fun look4SatDao(): Look4SatDao
} }
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the //val MIGRATION_1_2 = object : Migration(1, 2) {
* transceivers that were imported from a file, so that remote updates leave them alone. */ // override fun migrate(database: SupportSQLiteDatabase) {
val MIGRATION_1_2 = object : Migration(1, 2) { // 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))")
override fun migrate(db: SupportSQLiteDatabase) { // 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")
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0") // database.execSQL("DROP TABLE entries")
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0") // database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
} // }
} //}
@@ -30,6 +30,5 @@ data class SatEntry(
val argper: Double, val argper: Double,
val meanan: Double, val meanan: Double,
val catnum: Int, val catnum: Int,
val bstar: Double, val bstar: Double
val ndot: Double = 0.0
) )
@@ -32,7 +32,5 @@ data class SatRadio(
val uplinkHigh: Long?, val uplinkHigh: Long?,
val uplinkMode: String?, val uplinkMode: String?,
val isInverted: Boolean, val isInverted: Boolean,
val catnum: Int?, val catnum: Int?
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
) )
@@ -22,10 +22,7 @@ import android.content.Context
import android.hardware.SensorManager import android.hardware.SensorManager
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.location.LocationManager import android.location.LocationManager
import androidx.core.content.pm.PackageInfoCompat
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -152,24 +149,18 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideDatabaseRepo(): IDatabaseRepo { private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher) val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo) return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
} }
private fun provideLocalSource(): ILocalSource { private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME) val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build() val database = builder.fallbackToDestructiveMigration(false).build()
return LocalSource(database.look4SatDao()) return LocalSource(database.look4SatDao())
} }
private fun provideRemoteSource(): IRemoteSource { private fun provideRemoteSource(): IRemoteSource {
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4" return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
// Data providers ask clients to identify themselves, so that they can reach out to the
// developer instead of silently blocking every user of the app behind a misbehaving request
val client = OkHttpClient.Builder().addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
}.build()
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
} }
private fun provideSatelliteRepo(): ISatelliteRepo { private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -184,9 +175,7 @@ class MainContainer(private val context: Context) : IMainContainer {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val appPrefsFileName = "${context.packageName}_preferences" val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE) val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
val appVersionName = packageInfo.versionName ?: "4.0.4" return SettingsRepo(manager, appPreferences, appVersionName)
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
} }
} }
@@ -1,7 +1,5 @@
package com.rtbishop.look4sat.core.data.repository package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatDay import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
@@ -14,7 +12,6 @@ import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -52,46 +49,6 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
SatStatusPage(System.currentTimeMillis(), statuses, reportMap) SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
} }
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("name", submission.name)
put("report", submission.report)
put("callsign", submission.callsign)
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
}
val response = remoteSource.submitAmSatReport(payload.toString())
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
val (code, body) = response
val errorMessage = parseSubmitError(body)
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
} else {
AmSatReportSubmitResult(
success = false,
message = errorMessage.ifBlank { "HTTP $code" }
)
}
}
private fun parseSubmitError(json: String): String {
return try {
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
} catch (_: Exception) {
""
}
}
private fun parseSubmitReportId(json: String): String? {
return try {
val obj = JSONObject(json)
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
?: obj.optString("id").takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
/** Parse catalog JSON to list of satellite names */ /** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> { private fun parseCatalog(json: String): List<String> {
return try { return try {
@@ -43,9 +43,8 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream)))) val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
insertFresherEntries(entries) localSource.insertEntries(entries)
// report what the file contained: a valid file holding only stale data is not an error
importedCount = entries.size importedCount = entries.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -56,7 +55,7 @@ class DatabaseRepo(
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream)) val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers, isCustom = true) localSource.insertRadios(transceivers)
importedCount = transceivers.size importedCount = transceivers.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -77,27 +76,18 @@ class DatabaseRepo(
settingsRepo.updateDataSourcesStatus( settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code } (tleResults + radioResults).associate { (url, result) -> url to result.code }
) )
// parse fetched data concurrently, merging satellites by freshness instead of source order // parse fetched data concurrently, keeping the first occurrence per primary key
val parsedPerSource = tleResults.map { (url, result) -> // so sources listed higher in the dialog take priority over lower ones
val importedEntries = tleResults.flatMap { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty() result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
} }.distinctBy { it.catnum }
val importedRadios = radioResults.flatMap { (url, result) -> val importedRadios = radioResults.flatMap { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty() result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid } }.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database // insert parsed data into the database
insertFresherEntries(mergeEntries(parsedPerSource)) localSource.insertEntries(importedEntries)
// transceivers are a full snapshot: sources publish active entries only, so a retired localSource.insertRadios(importedRadios)
// transceiver simply disappears from the feed and has to be dropped locally as well. setUpdateSuccessful(System.currentTimeMillis())
// Imported ones are kept: no source can refresh them, so nothing would bring them back
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios, isCustom = false)
}
// keep the previous timestamp when every source failed, so the next launch retries
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
if (hasFetchedData) pruneStaleEntries()
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
} }
override suspend fun clearAllData() = withContext(dispatcher) { override suspend fun clearAllData() = withContext(dispatcher) {
@@ -133,62 +123,6 @@ class DatabaseRepo(
line.count { it == ',' } >= 4 line.count { it == ',' } >= 4
} }
/**
* Merges the data of every source: orbital elements always come from the set with the newest
* epoch, while the name comes from the first source that provides it. Source order is thus a
* naming preference only, which also keeps names stable when sources leapfrog each other.
*/
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
val preferredNames = mutableMapOf<Int, String>()
val freshestEntries = mutableMapOf<Int, OrbitalData>()
sourceEntries.forEach { entries ->
entries.forEach { entry ->
val name = entry.name.trim()
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
val current = freshestEntries[entry.catnum]
if (current == null || entry.epochDaynum > current.epochDaynum) {
freshestEntries[entry.catnum] = entry
}
}
}
return freshestEntries.values.map { entry ->
val name = preferredNames[entry.catnum]
if (name == null || name == entry.name) entry else entry.copy(name = name)
}
}
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
val storedEpochs = localSource.getEntriesEpochs()
val (fresherEntries, staleEntries) = entries.partition { entry ->
val storedEpoch = storedEpochs[entry.catnum]
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
}
localSource.insertEntries(fresherEntries)
// a reordered source list has to rename satellites right away, even the ones holding
// elements that are newer than the ones just parsed
if (staleEntries.isNotEmpty()) {
val storedNames = localSource.getEntriesNames()
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
if (renamedEntries.isNotEmpty()) {
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
}
}
}
/**
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
* disappeared from every catalog. Manually imported data ages out the same way, and every
* source republishes active satellites well within that window.
*/
private suspend fun pruneStaleEntries() {
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
val staleIds = localSource.getEntriesEpochs()
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
.keys.toList()
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
}
private suspend fun setUpdateSuccessful(timestamp: Long) { private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState( settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -40,13 +40,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.json.JSONObject import org.json.JSONObject
import java.util.Locale
class SettingsRepo( class SettingsRepo(
private val locationManager: LocationManager, private val locationManager: LocationManager,
private val preferences: SharedPreferences, private val preferences: SharedPreferences,
override val appVersionName: String, override val appVersionName: String
override val appVersionCode: Long,
) : ISettingsRepo, LocationListenerCompat { ) : ISettingsRepo, LocationListenerCompat {
private val keyBluetoothRotatorAddress = "bluetoothAddress" private val keyBluetoothRotatorAddress = "bluetoothAddress"
@@ -88,33 +86,19 @@ class SettingsRepo(
private val keyStationTimestamp = "stationTimestamp" private val keyStationTimestamp = "stationTimestamp"
private val keyUpdateTimestamp = "updateTimestamp" private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keySstvMode = "sstvMode" private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation" private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation" private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset" private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keySatellitesUrls = "satellitesUrls" private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls" private val keyTransceiversUrls = "transceiversUrls"
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled" private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled" private val keyTransceiversEnabled = "transceiversEnabled"
private val keySettingsVersion = "settingsVersion"
private val separatorComma = "," private val separatorComma = ","
private val separatorUrl = "\n" private val separatorUrl = "\n"
init {
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
// on the first launch instead of waiting up to 48 hours for the automatic update.
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
preferences.edit {
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
.forEach { key -> remove(key) }
putInt(keySettingsVersion, appVersionCode.toInt())
}
}
}
//region # Satellites selection settings //region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds()) private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes()) private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
@@ -399,23 +383,23 @@ class SettingsRepo(
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled) transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
) )
preferences.edit { preferences.edit {
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl)) putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl)) putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma)) putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma)) putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
} }
_dataSourcesSettings.value = normalized _dataSourcesSettings.value = normalized
} }
private fun getDataSourcesSettings(): DataSourcesSettings { private fun getDataSourcesSettings(): DataSourcesSettings {
val (satUrls, satEnabled) = parseSources( val (satUrls, satEnabled) = parseSources(
preferences.getString(keySatellitesUrls, null), preferences.getString(keySatelliteUrls, null),
preferences.getString(keySatellitesUrlsEnabled, null), preferences.getString(keySatelliteEnabled, null),
Sources.satelliteDataUrls Sources.satelliteDataUrls
) )
val (txUrls, txEnabled) = parseSources( val (txUrls, txEnabled) = parseSources(
preferences.getString(keyTransceiversUrls, null), preferences.getString(keyTransceiversUrls, null),
preferences.getString(keyTransceiversUrlsEnabled, null), preferences.getString(keyTransceiversEnabled, null),
Sources.transceiversDataUrls Sources.transceiversDataUrls
) )
return DataSourcesSettings( return DataSourcesSettings(
@@ -520,16 +504,4 @@ class SettingsRepo(
} }
preferences.edit { putString(keySatelliteOffsets, updated) } preferences.edit { putString(keySatelliteOffsets, updated) }
} }
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
override fun getAmSatCallsign(): String {
return preferences.getString(keyAmSatCallsign, "").orEmpty()
}
override fun setAmSatCallsign(callsign: String) {
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
}
//endregion
} }
@@ -43,16 +43,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return selectedOrbitalObjects return selectedOrbitalObjects
} }
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
override suspend fun renameEntries(names: Map<Int, String>) =
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity()) override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
override suspend fun deleteEntries() = look4SatDao.deleteEntries() override suspend fun deleteEntries() = look4SatDao.deleteEntries()
@@ -61,12 +51,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData( private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot this.raan, this.argper, this.meanan, this.catnum, this.bstar
) )
private fun OrbitalData.toEntity() = FrameworkEntry( private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot this.raan, this.argper, this.meanan, this.catnum, this.bstar
) )
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() } 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() return look4SatDao.getRadiosWithId(id).toDomainRadios()
} }
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) { override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) }) look4SatDao.insertRadios(radios.toFrameworkRadios())
} }
override suspend fun deleteRadios() = look4SatDao.deleteRadios() override suspend fun deleteRadios() = look4SatDao.deleteRadios()
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios() private fun DomainRadio.toFramework() = FrameworkRadio(
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
) )
private fun FrameworkRadio.toDomain() = DomainRadio( 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 this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
) )
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() } private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
//endregion //endregion
@@ -23,10 +23,8 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream import java.io.InputStream
class RemoteSource( class RemoteSource(
@@ -99,21 +97,4 @@ class RemoteSource(
null null
} }
} }
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
try {
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php")
.header("User-Agent", "Look4Sat")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
response.code to response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource submitAmSatReport exception: $exception")
null
}
}
} }
@@ -42,7 +42,6 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest { class DatabaseRepoTest {
@@ -50,10 +49,6 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher() private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher) private val dataParser = DataParser(dispatcher)
// fixtures carry a current epoch: stale entries are pruned on every remote update
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
@Test @Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) { fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites" val uri = "content://look4sat/import/satellites"
@@ -107,176 +102,18 @@ class DatabaseRepoTest {
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 }) assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
} }
@Test
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
val primaryUrl = "https://example.com/primary.txt"
val secondaryUrl = "https://example.com/secondary.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[primaryUrl] = { validTleStream() }
networkStreams[secondaryUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(primaryUrl, secondaryUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
val freshUri = "content://look4sat/import/fresh"
val staleUri = "content://look4sat/import/stale"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[freshUri] = { fresherTleStream() }
fileStreams[staleUri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
assertEquals(1, repository.updateTLEFromFile(freshUri))
assertEquals(1, repository.updateTLEFromFile(staleUri))
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
}
@Test
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
repository.updateFromRemote()
assertEquals(2, localSource.insertedRadios.size)
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
repository.updateFromRemote()
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
}
@Test
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
val preferredUrl = "https://example.com/preferred.txt"
val freshUrl = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[freshUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(freshUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals("ISS", localSource.insertedEntries.single().name)
// the user moves a source with older elements, but a better name, to the top of the list
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
satelliteUrls = listOf(preferredUrl, freshUrl),
transceiversUrls = emptyList()
)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single()
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
val failingUrl = "https://example.com/offline.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(failingUrl),
transceiversUrls = emptyList()
)
)
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// the update timestamp stays put so the data is not presented as fresh, while the recorded
// attempt keeps the next app launch from hammering a source that is already failing
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
}
@Test
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
val radioUri = "content://imported.json"
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[radioUri] = { customRadioStream() }
networkStreams[radioUrl] = { singleRadioStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTransceiversFromFile(radioUri)
repository.updateFromRemote()
// the remote snapshot replaces what the sources publish, the imported entry stays put
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
}
private fun validCsvStream(): InputStream = """ private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,${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() """.trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """ private fun validTleStream(): InputStream = """
ISS (ZARYA) 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 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun fresherTleStream(): InputStream = """
ISS
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun twoRadiosStream(): InputStream = """
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream()
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
private fun customRadioStream(): InputStream = """
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream()
} }
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
private class FakeRemoteSource : IRemoteSource { private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
@@ -291,14 +128,11 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
} }
private class FakeLocalSource : ILocalSource { private class FakeLocalSource : ILocalSource {
val insertedEntries = mutableListOf<OrbitalData>() val insertedEntries = mutableListOf<OrbitalData>()
val insertedRadios = mutableListOf<SatRadio>() private val insertedRadios = mutableListOf<SatRadio>()
private val customRadios = mutableListOf<String>()
override suspend fun getEntriesTotal(): Int = insertedEntries.size override suspend fun getEntriesTotal(): Int = insertedEntries.size
@@ -306,21 +140,6 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> =
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
override suspend fun getEntriesNames(): Map<Int, String> =
insertedEntries.associate { entry -> entry.catnum to entry.name }
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
}
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
insertedEntries.removeAll { entry -> entry.catnum in ids }
}
override suspend fun insertEntries(entries: List<OrbitalData>) { override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries insertedEntries += entries
} }
@@ -335,27 +154,18 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) { override suspend fun insertRadios(radios: List<SatRadio>) {
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
insertedRadios += radios insertedRadios += radios
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
} }
override suspend fun deleteRadios() { override suspend fun deleteRadios() {
insertedRadios.clear() insertedRadios.clear()
customRadios.clear()
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
} }
} }
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo { private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
override val appVersionName: String = "test" override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList()) override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
@@ -419,10 +229,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
} }
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -93,14 +93,6 @@ class SelectionRepoTest {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
override suspend fun renameEntries(names: Map<Int, String>) = Unit
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit override suspend fun deleteEntries() = Unit
@@ -111,11 +103,9 @@ class SelectionRepoTest {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit override suspend fun insertRadios(radios: List<SatRadio>) = Unit
override suspend fun deleteRadios() = Unit override suspend fun deleteRadios() = Unit
override suspend fun deleteManagedRadios() = Unit
} }
private class FakeSettingsRepo( private class FakeSettingsRepo(
@@ -123,7 +113,6 @@ class SelectionRepoTest {
) : ISettingsRepo { ) : ISettingsRepo {
override val appVersionName: String = "test" override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList()) override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
@@ -186,9 +175,7 @@ class SelectionRepoTest {
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
} }
} }
@@ -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
)
@@ -20,19 +20,17 @@ package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
* missing optional field should not cost the whole transceiver. */
@Serializable @Serializable
data class SatRadio( data class SatRadio(
@SerialName("uuid") val uuid: String, @SerialName("uuid") val uuid: String,
@SerialName("description") val info: String = "", @SerialName("description") val info: String,
@SerialName("alive") val isAlive: Boolean = true, @SerialName("alive") val isAlive: Boolean,
@SerialName("downlink_low") val downlinkLow: Long? = null, @SerialName("downlink_low") val downlinkLow: Long?,
@SerialName("downlink_high") val downlinkHigh: Long? = null, @SerialName("downlink_high") val downlinkHigh: Long?,
@SerialName("mode") val downlinkMode: String? = null, @SerialName("mode") val downlinkMode: String?,
@SerialName("uplink_low") val uplinkLow: Long? = null, @SerialName("uplink_low") val uplinkLow: Long?,
@SerialName("uplink_high") val uplinkHigh: Long? = null, @SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String? = null, @SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean = false, @SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int? = null @SerialName("norad_cat_id") val catnum: Int?
) )
@@ -37,42 +37,30 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min) val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
/** Absolute age of this element set, used to pick the freshest data among several sources. */
val epochDaynum: Double = epochToDaynum(epoch)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this) fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */ /** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean { fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false if (ndot == 0.0) return false
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 { private fun epochToDaynum(epoch: Double): Double {
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */ var year = kotlin.math.floor(epoch * 1E-3)
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0 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 {
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not val aYear = theYear - 1
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so val a = kotlin.math.floor(aYear / 100).toLong()
* freshness checks must always go through this conversion. val b = 2 - a + a / 4
*/ val i = kotlin.math.floor(365.25 * aYear).toLong()
fun epochToDaynum(epoch: Double): Double { return i + (30.6001 * 14).toLong() + 1720994.5 + b
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
}
} }
} }
@@ -1,14 +1,9 @@
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatStatusPage import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */ /** AMSAT satellite status data source */
interface IAmSatRepository { interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */ /** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage? suspend fun fetchStatus(): SatStatusPage?
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
} }
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.StateFlow
interface ISettingsRepo { interface ISettingsRepo {
val appVersionName: String val appVersionName: String
val appVersionCode: Long
//region # Satellites selection settings //region # Satellites selection settings
val selectedIds: StateFlow<List<Int>> val selectedIds: StateFlow<List<Int>>
@@ -86,9 +85,4 @@ interface ISettingsRepo {
fun getSatelliteOffset(catnum: Int): String fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String) fun setSatelliteOffset(catnum: Int, offset: String)
//endregion //endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
} }
@@ -26,18 +26,11 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem> suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
suspend fun getEntriesEpochs(): Map<Int, Double>
suspend fun getEntriesNames(): Map<Int, String>
suspend fun renameEntries(names: Map<Int, String>)
suspend fun deleteEntriesWithIds(ids: List<Int>)
suspend fun insertEntries(entries: List<OrbitalData>) suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries() suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) suspend fun insertRadios(radios: List<SatRadio>)
suspend fun deleteRadios() suspend fun deleteRadios()
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
suspend fun deleteManagedRadios()
} }
@@ -32,5 +32,4 @@ interface IRemoteSource {
suspend fun getNetworkStream(url: String): NetworkResult suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String? suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String? suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
} }
@@ -18,19 +18,17 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
// Ordered from the most specific to the most generic source: the first source that knows a
// satellite gets to name it, with the full catalog acting as the fallback.
val satelliteDataUrls = listOf( val satelliteDataUrls = listOf(
"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", "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"db.satnogs.org/api/tle/?format=3le" "db.satnogs.org/api/tle/?format=3le",
"amsat.org/tle/current/nasabare.txt",
"mmccants.org/tles/classfd.zip",
"r4uab.ru/satonline.txt",
"live.ariss.org/iss.txt"
) )
val transceiversDataUrls = listOf( val transceiversDataUrls = listOf(
"r4uab.ru/transmitters.json", "db.satnogs.org/api/transmitters/?format=json&status=active",
"db.satnogs.org/api/transmitters/?format=json&status=active" "r4uab.ru/transmitters.json"
) )
val satelliteModes = listOf( val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
@@ -33,19 +33,10 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
ignoreUnknownKeys = true ignoreUnknownKeys = true
coerceInputValues = true coerceInputValues = true
} }
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
private val celestrakCSVColumns = mapOf(
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
)
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines -> stream.bufferedReader().useLines { lines ->
val iterator = lines.iterator() lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
if (!iterator.hasNext()) return@useLines emptyList()
val columns = parseCSVColumns(iterator.next())
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
} }
} }
@@ -67,62 +58,34 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
} }
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching { private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
fun value(column: String) = values[columns.getValue(column)].trim() val name = values[0]
fun optionalValue(column: String) = val timestamp = values[2]
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0 val year = timestamp.substring(0, 4)
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()
val month = timestamp.substring(5, 7).toInt() val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt() val dayOfMonth = timestamp.substring(8, 10).toInt()
val hours = timestamp.substring(11, 13).toInt() val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
val minutes = timestamp.substring(14, 16).toInt() val day = dayInt.toString().padStart(3, '0')
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble() val hour = timestamp.substring(11, 13).toInt() * 3600000
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0 val min = timestamp.substring(14, 16).toInt() * 60000
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction 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()
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5: OrbitalData(
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0. name = name,
*/ epoch = epoch,
private fun parseCatnum(value: String): Int { meanmo = values[3].toDouble(),
val catnum = value.trim() eccn = values[4].toDouble(),
if (catnum.first().isDigit()) return catnum.toInt() incl = values[5].toDouble(),
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar()) raan = values[6].toDouble(),
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" } argper = values[7].toDouble(),
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt() 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 { private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
val line1 = tle[1] val line1 = tle[1]
@@ -136,7 +99,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
raan = line2.substring(17, 25).toDouble(), raan = line2.substring(17, 25).toDouble(),
argper = line2.substring(34, 42).toDouble(), argper = line2.substring(34, 42).toDouble(),
meanan = line2.substring(43, 51).toDouble(), meanan = line2.substring(43, 51).toDouble(),
catnum = 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()), bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
ndot = line1.substring(33, 43).trim().toDouble() ndot = line1.substring(33, 43).trim().toDouble()
) )
@@ -23,7 +23,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.math.abs
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class DataParserTest { class DataParserTest {
@@ -62,49 +61,8 @@ class DataParserTest {
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) { fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream) val parsedList = dataParser.parseCSVStream(validCSVStream)
assert(parsedList.size == 2) assert(parsedList.size == 2)
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8) assert(parsedList[0].epoch == 21320.51955234)
assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8) assert(parsedList[1].epoch == 24069.23963816)
}
@Test
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
val csvStream = """
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
""".trimIndent().byteInputStream()
val sat = dataParser.parseCSVStream(csvStream)[0]
assert(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035)
assert(sat.bstar == 0.31985E-4)
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
}
@Test
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
val csvStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
val parsedList = dataParser.parseCSVStream(csvStream)
assert(parsedList.size == 2)
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
}
@Test
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
val tleStream = """
NAVSTAR 43
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
val sat = dataParser.parseTLEStream(tleStream)[0]
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
assert(sat.catnum == 270111)
} }
@Test @Test
@@ -52,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -305,7 +305,6 @@ fun InfoDialog(
title: String, title: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAccept: () -> Unit, onAccept: () -> Unit,
extraAction: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
DialogShell(onDismissRequest = onDismiss) { padding -> DialogShell(onDismissRequest = onDismiss) { padding ->
@@ -324,10 +323,6 @@ fun InfoDialog(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
extraAction?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
} }
content() content()
@@ -430,11 +425,11 @@ private fun DialogShell(
@Composable @Composable
fun hasEnoughHeight(): Boolean = fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480) currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
@Composable @Composable
fun hasEnoughWidth(): Boolean = fun hasEnoughWidth(): Boolean =
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600) currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
@Composable @Composable
fun isVerticalLayout(): Boolean = !hasEnoughWidth() fun isVerticalLayout(): Boolean = !hasEnoughWidth()
@@ -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
@@ -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)
}
}
@@ -46,7 +46,7 @@ data object Spacing {
val extraSmall = 6.dp val extraSmall = 6.dp
val small = 8.dp val small = 8.dp
val medium = 12.dp val medium = 12.dp
val large = 12.dp val large = 16.dp
val extraLarge = 24.dp val extraLarge = 24.dp
} }
@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="960"
android:viewportHeight="24"> android:viewportHeight="960">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" /> android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
</vector> </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>
@@ -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>
@@ -121,8 +121,6 @@
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</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_sat_title">Datos de satélites</string>
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string> <string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string> <string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -120,8 +120,6 @@
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string> <string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
<string name="prefs_data_sources_sat_title">Данные спутников</string> <string name="prefs_data_sources_sat_title">Данные спутников</string>
<string name="prefs_data_sources_transceivers_title">Данные трансиверов</string> <string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string> <string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -121,8 +121,6 @@
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න</string> <string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න</string>
<string name="prefs_data_sources_sat_title">චන්ද්‍රිකා දත්ත</string> <string name="prefs_data_sources_sat_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_sources_transceivers_title">සම්ප්‍රේෂක දත්ත</string> <string name="prefs_data_sources_transceivers_title">සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_sat_hint">ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම්</string>
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්‍රමුඛතාව: සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string> <string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -152,8 +152,6 @@
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</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_sat_title">Uydu verileri</string>
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</string> <string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string> <string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -121,8 +121,6 @@
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string> <string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
<string name="prefs_data_sources_sat_title">Дані супутників</string> <string name="prefs_data_sources_sat_title">Дані супутників</string>
<string name="prefs_data_sources_transceivers_title">Дані трансиверів</string> <string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string> <string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -30,26 +30,6 @@
<string name="amsat_status_tlm_only">仅遥测</string> <string name="amsat_status_tlm_only">仅遥测</string>
<string name="amsat_status_not_heard">未听到</string> <string name="amsat_status_not_heard">未听到</string>
<string name="amsat_status_crew_active">乘组活跃</string> <string name="amsat_status_crew_active">乘组活跃</string>
<string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_confirm">确认上传</string>
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
<string name="amsat_upload_grid_none">未填写</string>
<string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string>
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
<string name="amsat_upload_active">活跃</string>
<string name="amsat_upload_tlm">遥测/信标</string>
<string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">模式:%s</string> <string name="sat_type_hint">模式:%s</string>
@@ -103,24 +83,6 @@
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string> <string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string> <string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">连接</string>
<string name="radar_disconnect">断开连接</string>
<string name="radar_off">关</string>
<string name="radar_stop">停止</string>
<string name="radar_track">跟踪</string>
<string name="radar_tx_base">TX 基频:</string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">授予权限</string>
<string name="sstv_listening">正在监听…</string>
<string name="sstv_mic_permission">需要麦克风权限</string>
<string name="sstv_mode_format">模式:%s</string>
<string name="sstv_mode_title">SSTV 模式</string>
<string name="sstv_no_signal">无信号</string>
<string name="sstv_no_transceiver">未选择收发器</string>
<string name="sstv_rx_format">RX:%s Hz</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">上一个</string> <string name="map_prev">上一个</string>
<string name="map_next">下一个</string> <string name="map_next">下一个</string>
@@ -141,12 +103,6 @@
<string name="prefs_donate_title">支持</string> <string name="prefs_donate_title">支持</string>
<string name="prefs_privacy_title">隐私政策</string> <string name="prefs_privacy_title">隐私政策</string>
<string name="settings_paired_devices">已配对的蓝牙设备:</string>
<string name="settings_split_mode">分频模式(仅 IC-705)</string>
<string name="prefs_lat_prefix">纬度:%s°</string>
<string name="prefs_lon_prefix">经度:%s°</string>
<string name="prefs_qth_prefix">QTH:%s</string>
<string name="prefs_updated_title">更新:%s</string> <string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string> <string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
@@ -179,8 +135,6 @@
<string name="prefs_data_sources_restore">恢复默认源</string> <string name="prefs_data_sources_restore">恢复默认源</string>
<string name="prefs_data_sources_sat_title">卫星数据</string> <string name="prefs_data_sources_sat_title">卫星数据</string>
<string name="prefs_data_sources_transceivers_title">收发器数据</string> <string name="prefs_data_sources_transceivers_title">收发器数据</string>
<string name="prefs_data_sources_sat_hint">靠前优先:卫星名称</string>
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string> <string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -241,7 +195,4 @@
\n• Libre Space Foundation (SatNOGS)</string> \n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- Main screen -->
<string name="tracking_format">跟踪:%s</string>
</resources> </resources>
@@ -56,14 +56,13 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241) * Added AMSAT status tracking page by MCKero6423 (#234)
\n\n* Overhauled sources import, added HTTP status by wty2019wty (#242) \n* Tweaked Radar track on reposition by atsunatsu (#235)
\nSources now have priority - the top ones define satellite names and transceiver data. \n* Tweaked linear transponder calc by atsunatsu (#236)
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog. \n* Added saving per-sat doppler offset by atsunatsu (#237)
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245) \n* Added custom freq offset setting to network reporting
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247) \n* Added ability to customize data sources via import
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248) \n* Fixed star chart in README by PingouinFerreux (#240)
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
</string> </string>
<!-- AMSAT Status screen --> <!-- AMSAT Status screen -->
@@ -84,26 +83,6 @@
<string name="amsat_status_tlm_only">Telemetry Only</string> <string name="amsat_status_tlm_only">Telemetry Only</string>
<string name="amsat_status_not_heard">Not Heard</string> <string name="amsat_status_not_heard">Not Heard</string>
<string name="amsat_status_crew_active">Crew Active</string> <string name="amsat_status_crew_active">Crew Active</string>
<string name="amsat_upload">Upload</string>
<string name="amsat_upload_back">Back</string>
<string name="amsat_upload_report">Upload report to AMSAT</string>
<string name="amsat_upload_status">Status</string>
<string name="amsat_upload_callsign">Callsign</string>
<string name="amsat_upload_grid">Grid</string>
<string name="amsat_upload_time_now">Time: current UTC %s</string>
<string name="amsat_upload_submit">Submit to AMSAT</string>
<string name="amsat_upload_confirm">Upload</string>
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
<string name="amsat_upload_grid_none">Not provided</string>
<string name="amsat_upload_success">Report uploaded</string>
<string name="amsat_upload_failed">Upload failed: %s</string>
<string name="amsat_upload_callsign_required">Callsign is required</string>
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
<string name="amsat_upload_active">Active</string>
<string name="amsat_upload_tlm">TLM/Beacon</string>
<string name="amsat_upload_not_heard">Not Heard</string>
<string name="amsat_upload_crew_active">Crew Active</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
@@ -134,24 +113,6 @@
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string> <string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string> <string name="radar_doppler_offset_hint">Offset (kHz)</string>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">Connect</string>
<string name="radar_disconnect">Disconnect</string>
<string name="radar_off">Off</string>
<string name="radar_stop">Stop</string>
<string name="radar_track">Track</string>
<string name="radar_tx_base">TX Base: </string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">Grant permission</string>
<string name="sstv_listening">Listening…</string>
<string name="sstv_mic_permission">Microphone access needed</string>
<string name="sstv_mode_format">Mode: %s</string>
<string name="sstv_mode_title">SSTV Mode</string>
<string name="sstv_no_signal">No signal</string>
<string name="sstv_no_transceiver">No transceiver selected</string>
<string name="sstv_rx_format">RX: %s Hz</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">Prev</string> <string name="map_prev">Prev</string>
<string name="map_next">Next</string> <string name="map_next">Next</string>
@@ -184,12 +145,6 @@
<string name="prefs_privacy_title">Privacy Policy</string> <string name="prefs_privacy_title">Privacy Policy</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string> <string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="settings_paired_devices">Paired Bluetooth Devices:</string>
<string name="settings_split_mode">Split mode (IC-705 only)</string>
<string name="prefs_lat_prefix">Lat: %s°</string>
<string name="prefs_lon_prefix">Lon: %s°</string>
<string name="prefs_qth_prefix">Qth: %s</string>
<string name="prefs_updated_title">Updated: %s</string> <string name="prefs_updated_title">Updated: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string> <string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -222,8 +177,6 @@
<string name="prefs_data_sources_restore">Restore default 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_sat_title">Satellites data</string>
<string name="prefs_data_sources_transceivers_title">Transceivers data</string> <string name="prefs_data_sources_transceivers_title">Transceivers data</string>
<string name="prefs_data_sources_sat_hint">Top priority affects satellite names</string>
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">Network</string>
@@ -286,7 +239,4 @@
</string> </string>
<string name="prefs_outro_license">The app comes with no warranty</string> <string name="prefs_outro_license">The app comes with no warranty</string>
<!-- Main screen -->
<string name="tracking_format">Tracking: %s</string>
</resources> </resources>
@@ -1,5 +1,7 @@
* Tweaked sun calculations, added tests by atsunatsu (#241) * Added AMSAT status tracking page by MCKero6423 (#234)
* Overhauled sources import, added HTTP status by wty2019wty (#242) * Tweaked Radar track on reposition by atsunatsu (#235)
* Localized AMSAT status screen for Chinese by atsunatsu (#245) * Tweaked linear transponder calc by atsunatsu (#236)
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247) * Added saving per-sat doppler offset by atsunatsu (#237)
* Added AMSAT satellite status reports submission by atsunatsu (#248) * Added custom freq offset setting to network reporting
* Added ability to customize data sources via import
* Fixed star chart in README by PingouinFerreux (#240)
@@ -21,7 +21,6 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay import org.osmdroid.views.overlay.Overlay
import kotlin.math.cos import kotlin.math.cos
@@ -40,11 +39,9 @@ import kotlin.math.sin
* exceeds 90°, i.e. the dot product of the two unit vectors is negative: * exceeds 90°, i.e. the dot product of the two unit vectors is negative:
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0 * dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
* *
* Performance: we sample one column per stepPx pixels (default 4) and draw * Performance: we sample one column per [stepPx] pixels (default 4) and draw
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig * filled vertical rectangles. On a 1080-wide screen this means ~270 trig
* evaluations per row, which is imperceptible. draw() is called on every * evaluations per row, which is imperceptible.
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
* single GeoPoint instance.
*/ */
class MapNightOverlay : Overlay() { class MapNightOverlay : Overlay() {
@@ -61,9 +58,6 @@ class MapNightOverlay : Overlay() {
private val rect = RectF() private val rect = RectF()
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return if (shadow) return
@@ -77,15 +71,6 @@ class MapNightOverlay : Overlay() {
val h = mapView.height val h = mapView.height
val stepPx = 4 // sample every N pixels — balance quality vs CPU val stepPx = 4 // sample every N pixels — balance quality vs CPU
// The map is never rotated, so latitude depends only on y and longitude only on x.
// Resolve the top/bottom latitudes once instead of once per column.
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
val sinLatTop = sin(latTopRad)
val cosLatTop = cos(latTopRad)
val sinLatBot = sin(latBotRad)
val cosLatBot = cos(latBotRad)
// We scan column by column. For each column we determine the longitude, // We scan column by column. For each column we determine the longitude,
// then find the latitude range that is in night and shade it. // then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night // Since longitude is constant along a vertical strip and the day/night
@@ -94,14 +79,22 @@ class MapNightOverlay : Overlay() {
var x = 0 var x = 0
while (x < w) { 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 lonRad = Math.toRadians(geoTop.longitude)
val cosLonDiff = cos(lonRad - sunLonRad) val cosLonDiff = cos(lonRad - sunLonRad)
// Top pixel geographic lat
val latTopRad = Math.toRadians(geoTop.latitude)
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
val latBotRad = Math.toRadians(geoBot.latitude)
// dot(sunVec, pointVec) < 0 → night // dot(sunVec, pointVec) < 0 → night
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff // dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
when { when {
dotTop < 0 && dotBot < 0 -> { dotTop < 0 && dotBot < 0 -> {
@@ -149,7 +142,7 @@ class MapNightOverlay : Overlay() {
var hi = yBot var hi = yBot
while (hi - lo > 1) { while (hi - lo > 1) {
val mid = (lo + hi) / 2 val mid = (lo + hi) / 2
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid val geo = proj.fromPixels(x, mid) ?: return mid
val latRad = Math.toRadians(geo.latitude) val latRad = Math.toRadians(geo.latitude)
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid if (dot < 0) hi = mid else lo = mid
@@ -62,6 +62,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
@@ -70,10 +71,6 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.XYTileSource import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
@@ -129,21 +126,6 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container)) val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView) MapScreen(uiState, viewModel::onAction, mapView)
} }
@@ -301,49 +283,19 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private val markerPool = HashMap<String, Marker>() private val markerPool = HashMap<String, Marker>()
private var lastMapView: MapView? = null private var lastMapView: MapView? = null
/**
* Above this many satellites inside the viewport labels are dropped in favor of a single
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
*/
private const val LABEL_LIMIT = 128
/** Degrees of space around the viewport so markers don't pop in at the edges */
private const val VIEWPORT_MARGIN = 8.0
/** Debounce for viewport-driven marker refreshes, in milliseconds */
private const val MAP_LISTENER_DELAY = 128L
/** Shared dot icon used when too many satellites are visible to label them */
private var dotIcon: Drawable? = null
/** Scratch list reused every frame to avoid per-tick allocation */
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
private var lastAction: ((OrbitalObject) -> Unit)? = null
private fun setPositions( private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>, posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView, mapView: MapView,
action: (OrbitalObject) -> Unit action: (OrbitalObject) -> Unit
) { ) {
try { try {
lastPositions = posMap
lastAction = action
// Clear caches when the MapView instance changes (e.g. config change) // Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) { if (lastMapView !== mapView) {
lastMapView = mapView lastMapView = mapView
markerPool.clear() markerPool.clear()
iconCache.evictAll() iconCache.evictAll()
dotIcon = null
footprintPolyline = null footprintPolyline = null
footprintPoints = null footprintPoints = null
mapView.addMapListener(DelayedMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
}, MAP_LISTENER_DELAY))
} }
// Reuse the existing FolderOverlay — creating a new one and replacing it // Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible. // causes osmdroid to detach shared Marker objects, making them invisible.
@@ -352,48 +304,17 @@ private fun setPositions(
} }
folder.items.clear() folder.items.clear()
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is val activeNames = HashSet<String>(posMap.size)
// exactly when marker labels are shown and drawing is most expensive. posMap.forEach { (satellite, geoPos) ->
visibleSats.clear()
if (mapView.width > 0 && mapView.height > 0) {
val box = mapView.boundingBox
val latNorth = box.latNorth + VIEWPORT_MARGIN
val latSouth = box.latSouth - VIEWPORT_MARGIN
val lonWest = box.lonWest - VIEWPORT_MARGIN
val lonEast = box.lonEast + VIEWPORT_MARGIN
val wrapsDateLine = box.lonWest > box.lonEast
for ((satellite, geoPos) in posMap) {
val lat = geoPos.latitude
if (lat !in latSouth..latNorth) continue
val lon = geoPos.longitude
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
else lon in lonWest..lonEast
if (isLonVisible) visibleSats.add(satellite to geoPos)
}
} else {
for (entry in posMap) visibleSats.add(entry.key to entry.value)
}
val showLabels = visibleSats.size <= LABEL_LIMIT
val activeNames = HashSet<String>(visibleSats.size)
for ((satellite, geoPos) in visibleSats) {
val name = satellite.data.name val name = satellite.data.name
activeNames.add(name) activeNames.add(name)
val marker = markerPool.getOrPut(name) { val marker = markerPool.getOrPut(name) {
Marker(mapView).apply { Marker(mapView).apply {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
// Resolve the satellite via relatedObject so the listener is allocated icon = getCachedTextIcon(name, mapView)
// once per marker instead of once per satellite per tick
setOnMarkerClickListener { clicked, _ ->
(clicked.relatedObject as? OrbitalObject)?.let(action)
true
}
} }
} }
marker.relatedObject = satellite
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
if (marker.icon !== icon) marker.icon = icon
// Update position in-place — reuse existing GeoPoint if available // Update position in-place — reuse existing GeoPoint if available
val pos = marker.position val pos = marker.position
if (pos != null) { if (pos != null) {
@@ -402,32 +323,22 @@ private fun setPositions(
} else { } else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude) marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
} }
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker) folder.add(marker)
} }
// Evict markers that are no longer tracked or no longer visible // Evict markers for satellites no longer tracked
markerPool.keys.retainAll(activeNames) val iter = markerPool.keys.iterator()
visibleSats.clear() while (iter.hasNext()) {
if (iter.next() !in activeNames) iter.remove()
}
} catch (e: Exception) { } catch (e: Exception) {
println(e) println(e)
} }
} }
/** Re-applies the last known positions against the new viewport after a pan or zoom */
private fun refreshPositions(mapView: MapView): Boolean {
val posMap = lastPositions ?: return false
val action = lastAction ?: return false
setPositions(posMap, mapView, action)
mapView.invalidate()
return true
}
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
val size = 20
val bitmap = createBitmap(size, size)
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
}
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable { private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
iconCache[name]?.let { return it } iconCache[name]?.let { return it }
val labelRect = Rect() val labelRect = Rect()
@@ -465,8 +376,9 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
private var footprintPolyline: Polyline? = null private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList<GeoPoint>? = null private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) { private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
try { try {
val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) { if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size) pts = ArrayList(rangeCircle.size)
@@ -596,28 +508,9 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(lifecycleObserver) lifecycle.addObserver(lifecycleObserver)
onDispose { lifecycle.removeObserver(lifecycleObserver) } onDispose { lifecycle.removeObserver(lifecycleObserver) }
} }
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
DisposableEffect(mapView) {
onDispose {
clearMapCaches()
mapView.onDetach()
}
}
return mapView return mapView
} }
private fun clearMapCaches() {
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
lastPositions = null
lastAction = null
lastMapView = null
}
@Composable @Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) { private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.feature.map
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState( data class MapState(
val mapData: MapData? = null, val mapData: MapData? = null,
@@ -28,7 +29,7 @@ data class MapState(
val stationPosition: GeoPos? = null, val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass, val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null, val track: List<List<GeoPos>>? = null,
val footprint: List<GeoPos>? = null, val footprint: OrbitalPos? = null,
val positions: Map<OrbitalObject, GeoPos>? = null, val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0, val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0, val sunLonDeg: Double = 0.0,
@@ -41,7 +42,6 @@ sealed interface MapAction {
data object SelectNext : MapAction data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction data class SelectDefaultItem(val catnum: Int) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
} }
data class MapData( data class MapData(
@@ -36,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.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -46,12 +45,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
class MapViewModel( class MapViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
@@ -72,9 +69,6 @@ class MapViewModel(
private var dataUpdateJob: Job? = null private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L private var dataUpdateRate = 1000L
private var selectedOrbitalObject: OrbitalObject? = null private var selectedOrbitalObject: OrbitalObject? = null
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
private val isScreenVisible = MutableStateFlow(true)
val uiState: StateFlow<MapState> = _uiState val uiState: StateFlow<MapState> = _uiState
init { init {
@@ -93,7 +87,6 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false) MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item) is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum) is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
} }
} }
@@ -131,11 +124,7 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject selectedOrbitalObject = orbitalObject
viewModelScope.launch { viewModelScope.launch {
dataUpdateJob?.cancelAndJoin() dataUpdateJob?.cancelAndJoin()
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every dataUpdateJob = launch {
// satelliteRepo call internally hops to Default and resumes back on the caller's
// dispatcher. On Main that posts one continuation per satellite per tick, which
// floods the looper and ANRs for users tracking thousands of objects.
dataUpdateJob = launch(Dispatchers.Default) {
val dateNow = Date() val dateNow = Date()
getStationPosition() getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
@@ -146,11 +135,9 @@ class MapViewModel(
else -> updateFreq else -> updateFreq
} }
while (isActive) { while (isActive) {
// Suspends while the map is off-screen instead of predicting into the void
isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(effectiveRate.milliseconds) delay(effectiveRate)
} }
} }
} }
@@ -200,8 +187,7 @@ class MapViewModel(
// 2. Derive footprint, info data, sun and moon position from already-computed state // 2. Derive footprint, info data, sun and moon position from already-computed state
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time) val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
// Range circle is 721 trig-heavy points — keep it on this background dispatcher val footprint = satPos
val footprint = satPos.getRangeCircle()
val mapData = buildMapData(selected, satPos, date) val mapData = buildMapData(selected, satPos, date)
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time) val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time) val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
@@ -308,7 +294,7 @@ class MapViewModel(
companion object { companion object {
/** Number of parallel chunks for satellite position computation */ /** 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 { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
@@ -31,12 +31,9 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -118,7 +115,6 @@ private fun RadarScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.layoutPadding() .layoutPadding()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(), .keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
@@ -49,7 +49,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -79,12 +78,12 @@ internal fun SstvPage(
) { ) {
Text(text = "🎙️", fontSize = 48.sp) Text(text = "🎙️", fontSize = 48.sp)
Text( Text(
text = stringResource(R.string.sstv_mic_permission), text = "Microphone access needed",
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Button(onClick = requestMicPermission) { Button(onClick = requestMicPermission) {
Text(stringResource(R.string.sstv_grant_permission)) Text("Grant permission")
} }
} }
} }
@@ -98,7 +97,7 @@ internal fun SstvPage(
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes } val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
val dismiss = { showModeDialog.value = false } val dismiss = { showModeDialog.value = false }
ConfirmDialog( ConfirmDialog(
title = stringResource(R.string.sstv_mode_title), title = "SSTV Mode",
onAccept = dismiss, onAccept = dismiss,
onCancel = dismiss onCancel = dismiss
) { ) {
@@ -168,7 +167,7 @@ internal fun SstvPage(
) )
} else { } else {
Text( 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, color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
@@ -197,7 +196,7 @@ internal fun SstvPage(
) { ) {
// Doppler-corrected downlink frequency hint // Doppler-corrected downlink frequency hint
OutlinedText( 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, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fillColor = MaterialTheme.colorScheme.primary, fillColor = MaterialTheme.colorScheme.primary,
@@ -228,7 +227,7 @@ internal fun SstvPage(
) { ) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
text = stringResource(R.string.sstv_mode_format, sstv.selectedMode), text = "Mode: ${sstv.selectedMode}",
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@@ -403,7 +403,7 @@ private fun ExpandedRadioControl(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = stringResource(R.string.radar_tx_base), text = "TX Base: ",
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -460,7 +460,7 @@ private fun ExpandedRadioControl(
onClick = { onAction(RadarAction.SetCtcssTone(null)) }, onClick = { onAction(RadarAction.SetCtcssTone(null)) },
label = { label = {
Text( Text(
text = stringResource(R.string.radar_off), text = "Off",
fontSize = 12.sp, fontSize = 12.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -492,19 +492,19 @@ private fun ExpandedRadioControl(
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) { if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
CardButton( CardButton(
onClick = { onAction(RadarAction.ConnectRadios) }, onClick = { onAction(RadarAction.ConnectRadios) },
text = stringResource(R.string.radar_connect), text = "Connect",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} else { } else {
CardButton( CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) }, onClick = { onAction(RadarAction.DisconnectRadios) },
text = stringResource(R.string.radar_disconnect), text = "Disconnect",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
CardButton( CardButton(
onClick = { onAction(RadarAction.ToggleTracking) }, 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) modifier = Modifier.weight(1f)
) )
} }
@@ -726,7 +726,7 @@ private fun DopplerFrequencyCalculator(
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) { if (offsetKHz.isEmpty()) {
Text( Text(
text = stringResource(R.string.radar_doppler_offset_hint), text = "Offset (kHz)",
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -17,10 +17,17 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Animatable
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.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -30,9 +37,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@@ -43,40 +51,50 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.source.NetworkResult import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.DragReorderState
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.dragHandle
import com.rtbishop.look4sat.core.presentation.dragLift
import com.rtbishop.look4sat.core.presentation.rememberDragReorderState
import com.rtbishop.look4sat.core.presentation.rememberDragRowState
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -177,13 +195,6 @@ 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 @Composable
fun DataSourcesDialog( fun DataSourcesDialog(
satelliteUrls: List<String>, satelliteUrls: List<String>,
@@ -199,46 +210,55 @@ fun DataSourcesDialog(
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries). // Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) } val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satEntries = remember { val satUrls = remember {
satelliteUrls.mapIndexed { i, url -> satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
}.toMutableStateList()
} }
val txEntries = remember { val txUrls = remember {
transceiversUrls.mapIndexed { i, url -> transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true }) }
}.toMutableStateList() val satEnabled = remember {
mutableStateMapOf<Long, Boolean>().apply {
satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } }
}
}
val txEnabled = remember {
mutableStateMapOf<Long, Boolean>().apply {
txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } }
}
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val dragState = rememberDragReorderState(listState) val satDraggedId = remember { mutableStateOf(-1L) }
val onRestoreSatDefaults = { val txDraggedId = remember { mutableStateOf(-1L) }
satEntries.clear() val onRestoreDefaults = {
satEntries.addAll(Sources.satelliteDataUrls.map { url -> SourceEntry(nextId.longValue++, url) }) nextId.longValue = (Sources.satelliteDataUrls.size + Sources.transceiversDataUrls.size).toLong()
Unit satUrls.clear()
} satUrls.addAll(Sources.satelliteDataUrls.mapIndexed { i, url -> i.toLong() to url })
val onRestoreTxDefaults = { txUrls.clear()
txEntries.clear() txUrls.addAll(
txEntries.addAll(Sources.transceiversDataUrls.map { url -> SourceEntry(nextId.longValue++, url) }) Sources.transceiversDataUrls.mapIndexed { i, url ->
(Sources.satelliteDataUrls.size + i).toLong() to url
}
)
satEnabled.clear()
txEnabled.clear()
Unit Unit
} }
val onAccept = { val onAccept = {
val satFiltered = satEntries.filter { it.url.isNotBlank() } val satFiltered = satUrls.filter { it.second.isNotBlank() }
val txFiltered = txEntries.filter { it.url.isNotBlank() } val txFiltered = txUrls.filter { it.second.isNotBlank() }
try { try {
onSave( onSave(
satFiltered.map { it.url }, satFiltered.map { it.second },
txFiltered.map { it.url }, txFiltered.map { it.second },
satFiltered.map { it.enabled }, satFiltered.map { satEnabled[it.first] ?: true },
txFiltered.map { it.enabled } txFiltered.map { txEnabled[it.first] ?: true }
) )
} finally { } finally {
onDismiss() onDismiss()
} }
} }
val satTitle = stringResource(R.string.prefs_data_sources_sat_title) 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 transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title)
val transceiversHint = stringResource(R.string.prefs_data_sources_transceivers_hint)
ConfirmDialog( ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title), title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onDismiss, onCancel = onDismiss,
@@ -250,7 +270,7 @@ fun DataSourcesDialog(
.fillMaxHeight(0.84f) .fillMaxHeight(0.84f)
.padding(horizontal = padding), .padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 0.dp) contentPadding = PaddingValues(vertical = 6.dp)
) { ) {
item { item {
Row( Row(
@@ -269,33 +289,40 @@ fun DataSourcesDialog(
) )
} }
} }
item {
CardButton(
onClick = onRestoreDefaults,
text = stringResource(R.string.prefs_data_sources_restore),
modifier = Modifier.fillMaxWidth()
)
}
sourceSection( sourceSection(
sectionKey = "sat", sectionKey = "sat",
label = satTitle, label = satTitle,
hint = satHint, urls = satUrls,
entries = satEntries, listState = listState,
dragState = dragState, draggedId = satDraggedId,
statusCodes = statusCodes, statusCodes = statusCodes,
onAdd = { satEntries.add(SourceEntry(nextId.longValue++, "")) }, enabledMap = satEnabled,
onRestore = onRestoreSatDefaults, onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) },
onMove = { from, to -> satEntries.add(to, satEntries.removeAt(from)) }, onAdd = { satUrls.add(nextId.longValue++ to "") },
onRemove = { i -> satEntries.removeAt(i) }, onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) },
onToggle = { i -> satEntries[i] = satEntries[i].copy(enabled = !satEntries[i].enabled) }, onRemove = { i -> satUrls.removeAt(i) },
onUrlChange = { i, v -> satEntries[i] = satEntries[i].copy(url = v) } onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
) )
sourceSection( sourceSection(
sectionKey = "tx", sectionKey = "tx",
label = transceiversTitle, label = transceiversTitle,
hint = transceiversHint, urls = txUrls,
entries = txEntries, listState = listState,
dragState = dragState, draggedId = txDraggedId,
statusCodes = statusCodes, statusCodes = statusCodes,
onAdd = { txEntries.add(SourceEntry(nextId.longValue++, "")) }, enabledMap = txEnabled,
onRestore = onRestoreTxDefaults, onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) },
onMove = { from, to -> txEntries.add(to, txEntries.removeAt(from)) }, onAdd = { txUrls.add(nextId.longValue++ to "") },
onRemove = { i -> txEntries.removeAt(i) }, onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) },
onToggle = { i -> txEntries[i] = txEntries[i].copy(enabled = !txEntries[i].enabled) }, onRemove = { i -> txUrls.removeAt(i) },
onUrlChange = { i, v -> txEntries[i] = txEntries[i].copy(url = v) } onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
) )
} }
} }
@@ -304,15 +331,15 @@ fun DataSourcesDialog(
private fun LazyListScope.sourceSection( private fun LazyListScope.sourceSection(
sectionKey: String, sectionKey: String,
label: String, label: String,
hint: String, urls: List<Pair<Long, String>>,
entries: SnapshotStateList<SourceEntry>, listState: LazyListState,
dragState: DragReorderState, draggedId: MutableState<Long>,
statusCodes: Map<String, Int>, statusCodes: Map<String, Int>,
enabledMap: Map<Long, Boolean>,
onToggle: (Long) -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onRestore: () -> Unit,
onMove: (Int, Int) -> Unit, onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit, onRemove: (Int) -> Unit,
onToggle: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit onUrlChange: (Int, String) -> Unit
) { ) {
item { item {
@@ -324,71 +351,65 @@ private fun LazyListScope.sourceSection(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconCard( IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
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 -> itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
val rowState = rememberDragRowState( val enabled = enabledMap[id] ?: true
dragState = dragState, val rowState = remember { DragRowState() }
items = entries, val scope = rememberCoroutineScope()
item = entry, val isDragging = draggedId.value == id
key = { "$sectionKey-${it.id}" }, val isLifted = isDragging || rowState.isSettling.value
onMove = onMove LaunchedEffect(isDragging) {
) if (!isDragging) return@LaunchedEffect
autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.dragLift(isLifted = rowState.isLifted, translationY = rowState.translationY) .draggedVisual(
.animateItem( isLifted = isLifted,
fadeInSpec = tween(durationMillis = 200), translationY = if (rowState.isSettling.value) {
// The dragged row repositions instantly, while its neighbors smoothly rowState.settleAnim.value
// slide out of the way (the "squeeze" effect).
placementSpec = if (rowState.isDragging) {
tween(durationMillis = 0)
} else { } else {
tween(durationMillis = 250, easing = FastOutSlowInEasing) rowState.offsetY.floatValue + rowState.scrollComp.floatValue
}
)
.animateItem(
fadeInSpec = spring(),
// The dragged row repositions instantly, while its neighbours spring
// out of the way (the "squeeze" effect).
placementSpec = if (isDragging) {
tween<IntOffset>(durationMillis = 0)
} else {
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
}, },
fadeOutSpec = tween(durationMillis = 200) fadeOutSpec = spring()
) )
) { ) {
Icon( Box(
painter = painterResource(R.drawable.ic_drag_handle), contentAlignment = Alignment.Center,
contentDescription = null,
tint = enabledTint,
modifier = Modifier modifier = Modifier
.padding(top = textFieldLabelOffset) .size(40.dp)
.dragHandle(rowState) .dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove)
) ) {
Spacer(modifier = Modifier.width(12.dp)) Icon(
painter = painterResource(R.drawable.ic_drag_handle),
contentDescription = null,
tint = enabledTint
)
}
OutlinedTextField( OutlinedTextField(
value = entry.url, value = url,
onValueChange = { onUrlChange(index, it) }, onValueChange = { onUrlChange(index, it) },
label = { label = { Text("Source URL") },
Row { supportingText = statusCodes[url]?.let { code ->
Text("Source URL") { Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) }
statusCodes[entry.url]?.let { code ->
Text(" - ${statusLabel(code)}", color = statusColor(code), fontSize = 12.sp)
}
}
}, },
trailingIcon = { trailingIcon = {
IconButton(onClick = { onRemove(index) }) { IconButton(onClick = { onRemove(index) }) {
@@ -399,31 +420,198 @@ private fun LazyListScope.sourceSection(
} }
}, },
singleLine = true, singleLine = true,
enabled = entry.enabled, enabled = enabled,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Checkbox( Checkbox(
checked = entry.enabled, checked = enabled,
onCheckedChange = { onToggle(index) }, onCheckedChange = { onToggle(id) }
modifier = Modifier.padding(top = textFieldLabelOffset)
) )
} }
} }
} }
/** /**
* Compensates for the visual weight of the [OutlinedTextField]'s floating label: the label * Per-row drag state, kept in one object to keep the drag-handle modifier signature small.
* 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. * [offsetY] is the compensated visual displacement during a drag (finger travel minus the
* heights of already-swapped neighbours), so the row stays glued to the finger. [fingerOffset]
* tracks the raw finger travel for edge auto-scroll and swap detection. [settleAnim] smoothly
* flies the lifted row back into its slot once the finger is released.
*/ */
private val textFieldLabelOffset = 8.dp private class DragRowState {
val offsetY = mutableFloatStateOf(0f)
val fingerOffset = mutableFloatStateOf(0f)
val scrollComp = mutableFloatStateOf(0f)
val startCenterY = mutableFloatStateOf(0f)
val settleAnim = Animatable(0f)
val isSettling = mutableStateOf(false)
}
/** Spring shared by neighbour "squeeze" and the settle-back animation: soft and slightly bouncy. */
private val reorderSpring = spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
/**
* Drag handle gesture that performs live reordering while dragging.
*
* [fingerOffset] tracks the raw finger travel, used for edge auto-scroll and for
* deciding when the dragged row's centre crosses a neighbour's midpoint. [offsetY]
* additionally subtracts the heights of already-swapped neighbours so the visual
* translation (see [draggedVisual]) keeps the row glued to the finger even as the
* layout slot moves. Rendering of the whole field is applied on the field itself so
* the entire row follows the finger, not just the handle icon.
*/
@Composable
private fun Modifier.dragHandle(
listState: LazyListState,
sectionKey: String,
entryId: Long,
urls: List<Pair<Long, String>>,
draggedId: MutableState<Long>,
rowState: DragRowState,
scope: CoroutineScope,
onMove: (from: Int, to: Int) -> Unit
): Modifier = pointerInput(entryId, sectionKey) {
fun reorderLive() {
val myIndex = urls.indexOfFirst { it.first == entryId }
if (myIndex !in urls.indices) return
val myCenter = rowState.startCenterY.floatValue + rowState.fingerOffset.floatValue
val visible = listState.layoutInfo.visibleItemsInfo
// Dragging down: swap when the dragged centre passes the next row's midpoint.
if (myIndex < urls.lastIndex) {
val next = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex + 1].first}" }
if (next != null && myCenter > next.offset + next.size / 2f) {
onMove(myIndex, myIndex + 1)
rowState.offsetY.floatValue -= next.size.toFloat()
return
}
}
// Dragging up: swap when the dragged centre passes the previous row's midpoint.
if (myIndex > 0) {
val prev = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex - 1].first}" }
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
onMove(myIndex, myIndex - 1)
rowState.offsetY.floatValue += prev.size.toFloat()
}
}
}
// 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.
fun finishDrag() {
val lastOffset = rowState.offsetY.floatValue + rowState.scrollComp.floatValue
if (kotlin.math.abs(lastOffset) < 1f) {
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = -1L
return
}
scope.launch {
rowState.settleAnim.snapTo(lastOffset)
rowState.isSettling.value = true
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = -1L
rowState.settleAnim.animateTo(0f, reorderSpring)
rowState.isSettling.value = false
}
}
detectDragGesturesAfterLongPress(
onDragStart = {
rowState.isSettling.value = false
val layout = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "$sectionKey-$entryId" }
rowState.startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = entryId
},
onDragEnd = ::finishDrag,
onDragCancel = ::finishDrag
) { change, dragAmount ->
change.consume()
if (draggedId.value != entryId) return@detectDragGesturesAfterLongPress
rowState.fingerOffset.floatValue += dragAmount.y
rowState.offsetY.floatValue += dragAmount.y
reorderLive()
}
}
@Composable
private fun Modifier.draggedVisual(isLifted: Boolean, translationY: Float): Modifier {
val shape = MaterialTheme.shapes.small
// Smoothly scale the row up/down as the lifted card appears and disappears.
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) {
// Solid card on top so the lifted row fully covers the row beneath it
// instead of showing a translucent overlap of both rows.
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 centre 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 centre captured at drag start and [fingerOffset] is the raw finger delta.
*/
private suspend fun autoScroll(
listState: LazyListState,
startCenterY: MutableFloatState,
fingerOffset: MutableFloatState,
scrollComp: 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) scrollComp.floatValue += listState.scrollBy(delta)
delay(16L)
}
}
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString() private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
@Composable @Composable
private fun statusColor(code: Int): Color = when (code) { private fun statusColor(code: Int): Color = when {
NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
in 200..299 -> Color(0xFF66BB6A) code in 200..299 -> Color(0xFF66BB6A)
else -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.error
} }
@@ -792,7 +980,7 @@ fun RadioControlDialog(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = stringResource(R.string.settings_split_mode), text = "Split mode (IC-705 only)",
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = splitModeLabelColor color = splitModeLabelColor
) )
@@ -832,7 +1020,7 @@ fun RadioControlDialog(
if (selectingFor.value.isNotBlank()) { if (selectingFor.value.isNotBlank()) {
Text( Text(
text = stringResource(R.string.settings_paired_devices), text = "Paired Bluetooth Devices:",
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -188,7 +188,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
// URLs for top bar // URLs for top bar
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
// val appUrl = stringResource(R.string.prefs_app_url) val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url) val donateUrl = stringResource(R.string.prefs_donate_url)
val fdroidTitle = stringResource(R.string.prefs_fdroid_title) val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
val fdroidUrl = stringResource(R.string.prefs_fdroid_url) val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
@@ -209,7 +209,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
} }
TopBar { TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -231,7 +231,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
} }
} else { } else {
TopBar { TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
TopCard( TopCard(
onClick = { dialogs.whatsNew = true }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
@@ -342,9 +342,9 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp)) Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude)) Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude)) Text(text = "Lon: ${settings.stationPos.longitude}°")
Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator)) Text(text = "Qth: ${settings.stationPos.qthLocator}")
} }
Spacer(modifier = Modifier.height(1.dp)) Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) { Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -206,6 +206,7 @@ class SettingsViewModel(
} }
} }
// endregion // endregion
companion object { companion object {
+10
View File
@@ -1,7 +1,17 @@
import com.android.build.api.dsl.LibraryExtension
plugins { plugins {
alias(libs.plugins.convention.featurePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
namespace = "com.rtbishop.look4sat.feature.status" namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
} }
@@ -1,6 +1,5 @@
package com.rtbishop.look4sat.feature.status package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -21,14 +20,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -57,32 +51,15 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */ /** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp private val TILE_WIDTH: Dp = 64.dp
private data class UploadOption(
val apiValue: String,
val labelResId: Int,
val color: Color
)
private val UPLOAD_OPTIONS = listOf(
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
)
/** /**
* Map AMSAT status text to Material3 colorScheme colors. * Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
@@ -120,33 +97,11 @@ fun SatStatusDestination() {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SatStatusScreen( SatStatusScreen(uiState) { viewModel.refresh() }
uiState = uiState,
refresh = viewModel::refresh,
onToggleUpload = viewModel::toggleUploadPanel,
onUploadReportChange = viewModel::setUploadReport,
onUploadCallsignChange = viewModel::setUploadCallsign,
onUploadGridChange = viewModel::setUploadGrid,
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
onDismissUpload = viewModel::collapseUploadPanel
)
} }
@Composable @Composable
private fun SatStatusScreen( private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
uiState: SatStatusUiState,
refresh: () -> Unit,
onToggleUpload: () -> Unit,
onUploadReportChange: (String) -> Unit,
onUploadCallsignChange: (String) -> Unit,
onUploadGridChange: (String) -> Unit,
onRequestSubmitUpload: () -> Unit,
onConfirmSubmitUpload: (String) -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismissUpload: () -> Unit
) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) } var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) { Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
@@ -181,23 +136,7 @@ private fun SatStatusScreen(
} }
selectedDay?.let { (status, day) -> selectedDay?.let { (status, day) ->
ReportDialog( ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
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()
}
)
} }
} }
@@ -351,57 +290,13 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
} }
} }
/** Report detail dialog (callsign / date / time / grid) + AMSAT report upload panel. */ /** Report detail dialog (callsign / date / time / grid) */
@Composable @Composable
private fun ReportDialog( private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
upload: AmSatUploadUiState,
onToggleUpload: () -> Unit,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit,
onConfirmSubmitReport: () -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog( InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
onAccept = onDismiss,
extraAction = {
CardButton(
onClick = onToggleUpload,
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
)
}
) {
AnimatedVisibility(visible = upload.isExpanded) {
AmSatUploadPanel(
statusName = statusName,
upload = upload,
onReportChange = onReportChange,
onCallsignChange = onCallsignChange,
onGridChange = onGridChange,
onRequestSubmitReport = onRequestSubmitReport
)
}
if (upload.isConfirmingSubmit) {
AmSatUploadConfirmDialog(
statusName = statusName,
upload = upload,
onConfirm = onConfirmSubmitReport,
onDismiss = onDismissSubmitConfirmation
)
}
if (dayReports.isEmpty()) { if (dayReports.isEmpty()) {
Text( Text(stringResource(id = R.string.amsat_no_reports))
text = stringResource(id = R.string.amsat_no_reports),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} else { } else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
@@ -431,171 +326,6 @@ private fun ReportDialog(
} }
} }
@Composable
private fun AmSatUploadPanel(
statusName: String,
upload: AmSatUploadUiState,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit
) {
val options = remember(statusName) {
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.amsat_upload_report),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.amsat_upload_status),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
options.forEach { option ->
val selected = upload.selectedReport == option.apiValue
FilterChip(
selected = selected,
onClick = { onReportChange(option.apiValue) },
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (selected) Color.White else option.color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(option.labelResId), maxLines = 1)
}
},
colors = FilterChipDefaults.filterChipColors(
containerColor = option.color.copy(alpha = 0.10f),
labelColor = MaterialTheme.colorScheme.onSurface,
selectedContainerColor = option.color,
selectedLabelColor = Color.White
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selected,
borderColor = option.color.copy(alpha = 0.60f),
selectedBorderColor = option.color
)
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = upload.callsign,
onValueChange = onCallsignChange,
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = upload.gridSquare,
onValueChange = onGridChange,
label = { Text(stringResource(R.string.amsat_upload_grid)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Button(
onClick = onRequestSubmitReport,
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (upload.isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(R.string.amsat_upload_submit))
}
}
upload.message?.let { message ->
if (message == UPLOAD_MESSAGE_SUCCESS) {
Text(
text = stringResource(R.string.amsat_upload_success),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
upload.error?.let { error ->
Text(
text = uploadErrorText(error),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun AmSatUploadConfirmDialog(
statusName: String,
upload: AmSatUploadUiState,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
val statusLabel = stringResource(option.labelResId)
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
text = {
Text(
text = stringResource(
R.string.amsat_upload_confirm_message,
statusName,
statusLabel,
upload.callsign,
grid
)
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.amsat_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
@Composable
private fun uploadErrorText(error: String): String {
return when (error) {
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
else -> stringResource(R.string.amsat_upload_failed, error)
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String { private fun formatFetchedAt(utcMs: Long): String {
@@ -613,10 +343,3 @@ private fun formatFetchedAt(utcMs: Long): String {
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss" "$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))
}
@@ -4,38 +4,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
const val AMSAT_REPORT_HEARD = "Heard"
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
const val UPLOAD_MESSAGE_SUCCESS = "success"
data class AmSatUploadUiState(
val isExpanded: Boolean = false,
val selectedReport: String = AMSAT_REPORT_HEARD,
val callsign: String = "",
val gridSquare: String = "",
val isSubmitting: Boolean = false,
val isConfirmingSubmit: Boolean = false,
val message: String? = null,
val error: String? = null
)
data class SatStatusUiState( data class SatStatusUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -43,13 +19,11 @@ data class SatStatusUiState(
val statuses: List<SatStatus> = emptyList(), val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(), val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L, val fetchedAtUtcMs: Long = 0L,
val error: String? = null, val error: String? = null
val upload: AmSatUploadUiState = AmSatUploadUiState()
) )
class SatStatusViewModel( class SatStatusViewModel(
private val amSatRepo: IAmSatRepository, private val amSatRepo: IAmSatRepository
private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -65,7 +39,15 @@ class SatStatusViewModel(
try { try {
val page = amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { 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 { } else {
_uiState.update { _uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed") it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
@@ -86,7 +68,14 @@ class SatStatusViewModel(
try { try {
val page = amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
applyStatusPage(page) _uiState.update {
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else { } else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") } _uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
} }
@@ -96,196 +85,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 { companion object {
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
SatStatusViewModel(container.amSatRepo, container.settingsRepo) SatStatusViewModel(container.amSatRepo)
} }
} }
} }
} }
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})?$")
+9 -13
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "447" appVersionCode = "446"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7" appVersionName = "4.4.6"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -12,25 +12,24 @@ jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.4.0" android-gradle-plugin = "9.3.1"
androidx-core-ktx = "1.19.0" androidx-core-ktx = "1.19.0"
androidx-core-splashscreen = "1.2.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-activity = "1.13.0"
compose-lifecycle = "2.11.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-coroutines = "1.11.0"
kotlin-serialization = "1.11.0" kotlin-serialization = "1.11.0"
other-detekt = "1.23.8" other-okhttp = "5.4.0"
other-okhttp = "5.5.0"
other-osmdroid = "6.1.20" other-osmdroid = "6.1.20"
test-junit4 = "4.13.2" test-junit4 = "4.13.2"
@@ -73,8 +72,6 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
#noinspection UnusedVersionCatalogEntry
other-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "other-detekt" }
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" } other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -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" } google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
other-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "other-detekt" }
# plugins defined by this project # plugins defined by this project
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" } convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" } convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
+2 -2
View File
@@ -1,7 +1,7 @@
#Sat Sep 12 11:29:59 BST 2026 #Tue Aug 11 14:16:53 CEST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME