Compare commits

..
Author SHA1 Message Date
atsunatsu 4cb11a8470 feat(satellites): fuzzy name search ignoring separators
The satellite search only matched exact continuous substrings, so names with
dashes, spaces or brackets (AO-7, ISS (ZARYA), FO-29) could not be found by
queries without those separators. The query is now normalized (lowercased,
non-alphanumeric separators stripped) and split into space-separated tokens;
every token must appear in the normalized satellite name. This makes 'ao7'
match 'AO-7 (AMSAT-OSCAR 7)' and 'iss zarya' match 'ISS (ZARYA)'. Numeric
queries still match catnum exactly.
2026-09-18 17:13:31 +08:00
Arty Bishop d471f02370 v4.4.7 - Data import overhaul, AMSAT reports, Map tweaks 2026-09-13 20:26:27 +01:00
Arty Bishop 9699d142fa Fixed RadarScreen padding, implemented basic Detekt setup 2026-09-13 17:29:38 +01:00
Arty Bishop 6f3e3351c9 Tweaked data source management, migrated DB and Settings 2026-09-13 15:19:23 +01:00
Arty Bishop d4a2fae0cd Updated dependencies and gradle plugin, minor fixes 2026-09-12 11:40:59 +01:00
dependabot[bot] d0bbb97f29 Bump actions/setup-java from 5 to 6 (#249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 08:40:23 +01:00
atsunatsuandatsunatsu c2fc5688b9 Added AMSAT satellite status reports submission (#248)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-03 08:39:34 +01:00
TianhengZhuang 796056a582 Extracted hardcoded UI strings to resources (en/zh) (#247) 2026-09-02 09:08:37 +01:00
atsunatsuandatsunatsu 9cac33dae7 Localized AMSAT status screen for Chinese (#245)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-02 09:05:45 +01:00
57 changed files with 3146 additions and 560 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '21'
@@ -68,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
@@ -252,7 +253,7 @@ private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${state.currentPass?.name ?: ""}",
text = stringResource(R.string.tracking_format, state.currentPass?.name ?: ""),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
+40
View File
@@ -5,6 +5,46 @@ plugins {
alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.other.detekt)
}
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
description = "Checks that source code satisfies detekt rules."
autoCorrect = false
}
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
description = "Applies code formatting rules to source code in-place."
autoCorrect = true
}
configure(listOf(detektCheck, detektApply)) {
configure {
group = "verification"
parallel = true
ignoreFailures = false
setSource(file(rootDir))
// Custom detekt config
config.setFrom("$projectDir/config/detekt/detekt.yml")
// Use default configuration on top of custom config
// (new detect rules will work out of the box after upgrading detekt version)
buildUponDefaultConfig = true
// Runs detekt for all files in the Gradle project and all subprojects without
// a need to configure detekt plugin in every subproject.
include("**/*.kt", "**/*.kts")
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
reports {
html.required.set(true)
xml.required.set(true)
}
}
dependencies {
detektPlugins(libs.other.detekt.formatting)
}
}
tasks.register("clean", Delete::class.java) {
+808
View File
@@ -0,0 +1,808 @@
build:
maxIssues: 0
excludeCorrectable: false
weights:
# complexity: 2
# LongParameterList: 1
# style: 1
# comments: 1
config:
validation: true
warningsAsErrors: false
checkExhaustiveness: false
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
excludes: ''
processors:
active: true
exclude:
- 'DetektProgressListener'
# - 'KtFileCountProcessor'
# - 'PackageCountProcessor'
# - 'ClassCountProcessor'
# - 'FunctionCountProcessor'
# - 'PropertyCountProcessor'
# - 'ProjectComplexityProcessor'
# - 'ProjectCognitiveComplexityProcessor'
# - 'ProjectLLOCProcessor'
# - 'ProjectCLOCProcessor'
# - 'ProjectLOCProcessor'
# - 'ProjectSLOCProcessor'
# - 'LicenseHeaderLoaderExtension'
console-reports:
active: true
exclude:
- 'ProjectStatisticsReport'
- 'ComplexityReport'
- 'NotificationReport'
- 'FindingsReport'
- 'FileBasedFindingsReport'
# - 'LiteFindingsReport'
output-reports:
active: true
exclude:
# - 'TxtOutputReport'
# - 'XmlOutputReport'
- 'HtmlOutputReport'
# - 'MdOutputReport'
# - 'SarifOutputReport'
comments:
active: true
AbsentOrWrongFileLicense:
active: false
licenseTemplateFile: 'license.template'
licenseTemplateIsRegex: false
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
DeprecatedBlockTag:
active: false
EndOfSentenceFormat:
active: false
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
KDocReferencesNonPublicProperty:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
OutdatedDocumentation:
active: false
matchTypeParameters: true
matchDeclarationsOrder: true
allowParamOnConstructorProperties: false
UndocumentedPublicClass:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
searchInProtectedClass: false
UndocumentedPublicFunction:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchProtectedFunction: false
UndocumentedPublicProperty:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchProtectedProperty: false
complexity:
active: true
CognitiveComplexMethod:
active: false
threshold: 15
ComplexCondition:
active: true
threshold: 4
ComplexInterface:
active: false
threshold: 10
includeStaticDeclarations: false
includePrivateDeclarations: false
ignoreOverloaded: false
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
nestingFunctions:
- 'also'
- 'apply'
- 'forEach'
- 'isNotNull'
- 'ifNull'
- 'let'
- 'run'
- 'use'
- 'with'
# Composables describe UI trees declaratively and naturally accumulate more branches than
# regular functions. Exclude them here and rely on code review + decomposition instead.
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
LabeledExpression:
active: false
ignoredLabels: [ ]
LargeClass:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
threshold: 600
LongMethod:
active: true
# Composable UI functions are declarative trees; 60 lines is too strict for them.
# Screen/Slots files are excluded — keep them reasonable via code review.
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
threshold: 60
LongParameterList:
active: false
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: false
ignoreDataClasses: true
ignoreAnnotatedParameter: [ ]
MethodOverloading:
active: false
threshold: 6
NamedArguments:
active: false
threshold: 3
ignoreArgumentsMatchingNames: false
NestedBlockDepth:
active: true
threshold: 4
NestedScopeFunctions:
active: false
threshold: 1
functions:
- 'kotlin.apply'
- 'kotlin.run'
- 'kotlin.with'
- 'kotlin.let'
- 'kotlin.also'
ReplaceSafeCallChainWithRun:
active: false
StringLiteralDuplication:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
coroutines:
active: true
GlobalCoroutineUsage:
active: false
InjectDispatcher:
active: true
dispatcherNames:
- 'IO'
- 'Default'
- 'Unconfined'
RedundantSuspendModifier:
active: true
SleepInsteadOfDelay:
active: true
SuspendFunSwallowedCancellation:
active: false
SuspendFunWithCoroutineScopeReceiver:
active: false
SuspendFunWithFlowReturnType:
active: true
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: '_|(ignore|expected).*'
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverridden: true
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyTryBlock:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: true
methodNames:
- 'equals'
- 'finalize'
- 'hashCode'
- 'toString'
InstanceOfCheckForException:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
NotImplementedDeclaration:
active: false
ObjectExtendsThrowable:
active: false
PrintStackTrace:
active: true
RethrowCaughtException:
active: true
ReturnFromFinally:
active: true
ignoreLabeled: false
SwallowedException:
active: true
ignoredExceptionTypes:
- 'InterruptedException'
- 'MalformedURLException'
- 'NumberFormatException'
- 'ParseException'
allowedExceptionNameRegex: '_|(ignore|expected).*'
ThrowingExceptionFromFinally:
active: true
ThrowingExceptionInMain:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
exceptions:
- 'ArrayIndexOutOfBoundsException'
- 'Exception'
- 'IllegalArgumentException'
- 'IllegalMonitorStateException'
- 'IllegalStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
ThrowingNewInstanceOfSameException:
active: true
TooGenericExceptionCaught:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
- 'Exception'
- 'IllegalMonitorStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
allowedExceptionNameRegex: '_|(ignore|expected).*'
TooGenericExceptionThrown:
active: true
exceptionNames:
- 'Error'
- 'Exception'
- 'RuntimeException'
- 'Throwable'
formatting:
active: true
ArgumentListWrapping:
active: false
ParameterListWrapping:
active: false
NoWildcardImports:
active: false
MaximumLineLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
naming:
active: true
BooleanPropertyNaming:
active: false
allowedPattern: '^(is|has|are)'
ClassNaming:
active: true
classPattern: '[A-Z][a-zA-Z0-9_]*'
ConstructorParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
forbiddenName: [ ]
FunctionMaxLength:
active: false
maximumFunctionNameLength: 30
FunctionMinLength:
active: false
minimumFunctionNameLength: 3
FunctionNaming:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
excludeClassPattern: '$^'
FunctionParameterNaming:
active: true
parameterPattern: '[a-zA-Z][A-Za-z0-9]*'
excludeClassPattern: '$^'
InvalidPackageDeclaration:
active: true
rootPackage: ''
requireRootInDeclaration: false
LambdaParameterNaming:
active: false
parameterPattern: '[a-z][A-Za-z0-9]*|_'
MatchingDeclarationName:
active: false
mustBeFirst: false
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
NoNameShadowing:
active: true
NonBooleanPropertyPrefixedWithIs:
active: false
ObjectPropertyNaming:
active: true
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
TopLevelPropertyNaming:
active: true
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: false
maximumVariableNameLength: 64
VariableMinLength:
active: false
minimumVariableNameLength: 1
VariableNaming:
active: true
variablePattern: '[a-zA-Z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-zA-Z][A-Za-z0-9]*'
excludeClassPattern: '$^'
performance:
active: true
ArrayPrimitive:
active: true
CouldBeSequence:
active: false
threshold: 3
ForEachOnRange:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
SpreadOperator:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
UnnecessaryPartOfBinaryExpression:
active: false
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
AvoidReferentialEquality:
active: true
forbiddenTypePatterns:
- 'kotlin.String'
CastNullableToNonNullableType:
active: false
CastToNullableType:
active: false
Deprecation:
active: false
DontDowncastCollectionTypes:
active: false
DoubleMutabilityForCollection:
active: true
mutableTypes:
- 'kotlin.collections.MutableList'
- 'kotlin.collections.MutableMap'
- 'kotlin.collections.MutableSet'
- 'java.util.ArrayList'
- 'java.util.LinkedHashSet'
- 'java.util.HashSet'
- 'java.util.LinkedHashMap'
- 'java.util.HashMap'
ElseCaseInsteadOfExhaustiveWhen:
active: false
ignoredSubjectTypes: [ ]
EqualsAlwaysReturnsTrueOrFalse:
active: true
EqualsWithHashCodeExist:
active: true
ExitOutsideMain:
active: false
ExplicitGarbageCollectionCall:
active: true
HasPlatformType:
active: true
IgnoredReturnValue:
active: true
restrictToConfig: true
returnValueAnnotations:
- 'CheckResult'
- '*.CheckResult'
- 'CheckReturnValue'
- '*.CheckReturnValue'
ignoreReturnValueAnnotations:
- 'CanIgnoreReturnValue'
- '*.CanIgnoreReturnValue'
returnValueTypes:
- 'kotlin.sequences.Sequence'
- 'kotlinx.coroutines.flow.*Flow'
- 'java.util.stream.*Stream'
ignoreFunctionCall: [ ]
ImplicitDefaultLocale:
active: true
ImplicitUnitReturnType:
active: false
allowExplicitReturnType: true
InvalidRange:
active: true
IteratorHasNextCallsNextMethod:
active: true
IteratorNotThrowingNoSuchElementException:
active: true
LateinitUsage:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
ignoreOnClassesPattern: ''
MapGetWithNotNullAssertionOperator:
active: true
MissingPackageDeclaration:
active: false
excludes: [ '**/*.kts' ]
NullCheckOnMutableProperty:
active: false
NullableToStringCall:
active: false
PropertyUsedBeforeDeclaration:
active: false
UnconditionalJumpStatementInLoop:
active: false
UnnecessaryNotNullCheck:
active: false
UnnecessaryNotNullOperator:
active: true
UnnecessarySafeCall:
active: true
UnreachableCatchBlock:
active: true
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
UnsafeCast:
active: true
UnusedUnaryOperator:
active: true
UselessPostfixExpression:
active: true
WrongEqualsTypeParameter:
active: true
style:
active: true
AlsoCouldBeApply:
active: false
BracesOnIfStatements:
active: false
singleLine: 'never'
multiLine: 'always'
BracesOnWhenStatements:
active: false
singleLine: 'necessary'
multiLine: 'consistent'
CanBeNonNullable:
active: false
CascadingCallWrapping:
active: false
includeElvis: true
ClassOrdering:
active: false
CollapsibleIfStatements:
active: false
DataClassContainsFunctions:
active: false
conversionFunctionPrefix:
- 'to'
allowOperators: false
DataClassShouldBeImmutable:
active: false
DestructuringDeclarationWithTooManyEntries:
active: true
maxDestructuringEntries: 3
DoubleNegativeLambda:
active: false
negativeFunctions:
- reason: 'Use `takeIf` instead.'
value: 'takeUnless'
- reason: 'Use `all` instead.'
value: 'none'
negativeFunctionNameParts:
- 'not'
- 'non'
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: false
ExplicitCollectionElementAccessMethod:
active: false
ExplicitItLambdaParameter:
active: true
ExpressionBodySyntax:
active: false
includeLineWrapping: false
ForbiddenAnnotation:
active: false
annotations:
- reason: 'it is a java annotation. Use `Suppress` instead.'
value: 'java.lang.SuppressWarnings'
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
value: 'java.lang.Deprecated'
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
value: 'java.lang.annotation.Documented'
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
value: 'java.lang.annotation.Target'
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
value: 'java.lang.annotation.Retention'
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
value: 'java.lang.annotation.Repeatable'
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
value: 'java.lang.annotation.Inherited'
ForbiddenComment:
active: true
comments:
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
value: 'FIXME:'
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
value: 'STOPSHIP:'
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
value: 'TODO:'
allowedPatterns: ''
ForbiddenImport:
active: false
imports: [ ]
forbiddenPatterns: ''
ForbiddenMethodCall:
active: false
methods:
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.print'
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.println'
ForbiddenSuppress:
active: false
rules: [ ]
ForbiddenVoid:
active: true
ignoreOverridden: false
ignoreUsageInGenerics: false
FunctionOnlyReturningConstant:
active: true
ignoreOverridableFunction: true
ignoreActualFunction: true
excludedFunctions: [ ]
LoopWithTooManyJumpStatements:
active: true
maxJumpCount: 1
MagicNumber:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: false
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
MandatoryBracesLoops:
active: false
MaxChainedCallsOnSameLine:
active: false
maxChainedCalls: 5
MaxLineLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: true
excludeRawStrings: true
MayBeConst:
active: true
ModifierOrder:
active: true
MultilineLambdaItParameter:
active: false
MultilineRawStringIndentation:
active: false
indentSize: 4
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
NestedClassesVisibility:
active: true
NewLineAtEndOfFile:
active: true
NoTabs:
active: false
NullableBooleanCheck:
active: false
ObjectLiteralToLambda:
active: true
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: false
OptionalWhenBraces:
active: false
PreferToOverPairSyntax:
active: false
ProtectedMemberInFinalClass:
active: true
RedundantExplicitType:
active: false
RedundantHigherOrderMapUsage:
active: true
RedundantVisibilityModifierRule:
active: false
ReturnCount:
active: true
max: 3
excludedFunctions:
- 'equals'
excludeLabeled: false
excludeReturnFromLambda: true
# Guard clauses (early returns for preconditions) are idiomatic Kotlin and should not
# count toward the return limit. E.g. `if (x == null) return null` at function top.
excludeGuardClauses: true
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: true
SpacingBetweenPackageAndImports:
active: false
StringShouldBeRawString:
active: false
maxEscapedCharacterCount: 2
ignoredCharacters: [ ]
ThrowsCount:
active: true
max: 2
excludeGuardClauses: false
TrailingWhitespace:
active: false
TrimMultilineRawString:
active: false
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
UnderscoresInNumericLiterals:
active: false
acceptableLength: 4
allowNonStandardGrouping: false
UnnecessaryAbstractClass:
active: true
UnnecessaryAnnotationUseSiteTarget:
active: false
UnnecessaryApply:
active: true
UnnecessaryBackticks:
active: false
UnnecessaryBracesAroundTrailingLambda:
active: false
UnnecessaryFilter:
active: true
UnnecessaryInheritance:
active: true
UnnecessaryInnerClass:
active: false
UnnecessaryLet:
active: false
UnnecessaryParentheses:
active: false
allowForUnclearPrecedence: false
UntilInsteadOfRangeTo:
active: false
UnusedImports:
active: false
UnusedParameter:
active: true
allowedNames: 'ignored|expected'
UnusedPrivateClass:
active: true
UnusedPrivateMember:
active: true
allowedNames: '[A-Z][a-zA-Z0-9]*Preview'
UnusedPrivateProperty:
active: true
allowedNames: '_|ignored|expected|serialVersionUID'
UseAnyOrNoneInsteadOfFind:
active: true
UseArrayLiteralsInAnnotations:
active: true
UseCheckNotNull:
active: true
UseCheckOrError:
active: true
UseDataClass:
active: false
allowVars: false
UseEmptyCounterpart:
active: false
UseIfEmptyOrIfBlank:
active: false
UseIfInsteadOfWhen:
active: false
ignoreWhenContainingVariableDeclaration: false
UseIsNullOrEmpty:
active: true
UseLet:
active: false
UseOrEmpty:
active: true
UseRequire:
active: true
UseRequireNotNull:
active: true
UseSumOfInsteadOfFlatMapSize:
active: false
UselessCallOnNotNull:
active: true
UtilityClassWithPublicConstructor:
active: true
VarCouldBeVal:
active: true
ignoreLateinitVar: false
WildcardImport:
active: false
excludeImports:
- 'java.util.*'
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
@@ -39,6 +40,18 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
@Query("SELECT catnum, epoch FROM entries")
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
@Query("SELECT catnum, name FROM entries")
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
suspend fun renameEntry(catnum: Int, name: String)
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
suspend fun deleteEntriesWithIds(ids: List<Int>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntries(entries: List<SatEntry>)
@@ -65,4 +78,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios")
suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
}
@@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
const val DATABASE_NAME = "Look4SatDBv400"
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao
}
//val MIGRATION_1_2 = object : Migration(1, 2) {
// override fun migrate(database: SupportSQLiteDatabase) {
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
// database.execSQL("DROP TABLE entries")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
// }
//}
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
* transceivers that were imported from a file, so that remote updates leave them alone. */
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
}
}
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double,
val meanan: Double,
val catnum: Int,
val bstar: Double
val bstar: Double,
val ndot: Double = 0.0
)
@@ -32,5 +32,7 @@ data class SatRadio(
val uplinkHigh: Long?,
val uplinkMode: String?,
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,7 +22,10 @@ import android.content.Context
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager
import androidx.core.content.pm.PackageInfoCompat
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -149,18 +152,24 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.fallbackToDestructiveMigration(false).build()
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
return LocalSource(database.look4SatDao())
}
private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
// Data providers ask clients to identify themselves, so that they can reach out to the
// developer instead of silently blocking every user of the app behind a misbehaving request
val client = OkHttpClient.Builder().addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
}.build()
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
}
private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -175,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
return SettingsRepo(manager, appPreferences, appVersionName)
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val appVersionName = packageInfo.versionName ?: "4.0.4"
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
}
}
@@ -1,5 +1,7 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
@@ -12,6 +14,7 @@ import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
@@ -49,6 +52,46 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("name", submission.name)
put("report", submission.report)
put("callsign", submission.callsign)
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
}
val response = remoteSource.submitAmSatReport(payload.toString())
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
val (code, body) = response
val errorMessage = parseSubmitError(body)
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
} else {
AmSatReportSubmitResult(
success = false,
message = errorMessage.ifBlank { "HTTP $code" }
)
}
}
private fun parseSubmitError(json: String): String {
return try {
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
} catch (_: Exception) {
""
}
}
private fun parseSubmitReportId(json: String): String? {
return try {
val obj = JSONObject(json)
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
?: obj.optString("id").takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
@@ -43,8 +43,9 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
insertFresherEntries(entries)
// report what the file contained: a valid file holding only stale data is not an error
importedCount = entries.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -55,7 +56,7 @@ class DatabaseRepo(
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers)
localSource.insertRadios(transceivers, isCustom = true)
importedCount = transceivers.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -76,18 +77,27 @@ class DatabaseRepo(
settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code }
)
// parse fetched data concurrently, keeping the first occurrence per primary key
// so sources listed higher in the dialog take priority over lower ones
val importedEntries = tleResults.flatMap { (url, result) ->
// parse fetched data concurrently, merging satellites by freshness instead of source order
val parsedPerSource = tleResults.map { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
}.distinctBy { it.catnum }
}
val importedRadios = radioResults.flatMap { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis())
insertFresherEntries(mergeEntries(parsedPerSource))
// transceivers are a full snapshot: sources publish active entries only, so a retired
// transceiver simply disappears from the feed and has to be dropped locally as well.
// Imported ones are kept: no source can refresh them, so nothing would bring them back
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios, isCustom = false)
}
// keep the previous timestamp when every source failed, so the next launch retries
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
if (hasFetchedData) pruneStaleEntries()
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
}
override suspend fun clearAllData() = withContext(dispatcher) {
@@ -123,6 +133,62 @@ class DatabaseRepo(
line.count { it == ',' } >= 4
}
/**
* Merges the data of every source: orbital elements always come from the set with the newest
* epoch, while the name comes from the first source that provides it. Source order is thus a
* naming preference only, which also keeps names stable when sources leapfrog each other.
*/
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
val preferredNames = mutableMapOf<Int, String>()
val freshestEntries = mutableMapOf<Int, OrbitalData>()
sourceEntries.forEach { entries ->
entries.forEach { entry ->
val name = entry.name.trim()
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
val current = freshestEntries[entry.catnum]
if (current == null || entry.epochDaynum > current.epochDaynum) {
freshestEntries[entry.catnum] = entry
}
}
}
return freshestEntries.values.map { entry ->
val name = preferredNames[entry.catnum]
if (name == null || name == entry.name) entry else entry.copy(name = name)
}
}
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
val storedEpochs = localSource.getEntriesEpochs()
val (fresherEntries, staleEntries) = entries.partition { entry ->
val storedEpoch = storedEpochs[entry.catnum]
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
}
localSource.insertEntries(fresherEntries)
// a reordered source list has to rename satellites right away, even the ones holding
// elements that are newer than the ones just parsed
if (staleEntries.isNotEmpty()) {
val storedNames = localSource.getEntriesNames()
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
if (renamedEntries.isNotEmpty()) {
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
}
}
}
/**
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
* disappeared from every catalog. Manually imported data ages out the same way, and every
* source republishes active satellites well within that window.
*/
private suspend fun pruneStaleEntries() {
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
val staleIds = localSource.getEntriesEpochs()
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
.keys.toList()
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
}
private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -114,12 +114,29 @@ class SelectionRepo(
/**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item.
*
* Fuzzy search: the query is split into space-separated tokens and every
* token must appear in the satellite name after both sides are normalized
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
* previously failed whenever the name contained a separator the query lacked.
*/
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items
val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase()
return items.filter { it.name.lowercase().contains(lowerQuery) }
val tokens = query.split(' ')
.map { normalizeForSearch(it) }
.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return items
return items.filter { item ->
val normalizedName = normalizeForSearch(item.name)
tokens.all { normalizedName.contains(it) }
}
}
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
}
@@ -40,11 +40,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONObject
import java.util.Locale
class SettingsRepo(
private val locationManager: LocationManager,
private val preferences: SharedPreferences,
override val appVersionName: String
override val appVersionName: String,
override val appVersionCode: Long,
) : ISettingsRepo, LocationListenerCompat {
private val keyBluetoothRotatorAddress = "bluetoothAddress"
@@ -86,19 +88,33 @@ class SettingsRepo(
private val keyStationTimestamp = "stationTimestamp"
private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keySatelliteUrls = "satelliteUrls"
private val keySatellitesUrls = "satellitesUrls"
private val keyTransceiversUrls = "transceiversUrls"
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
private val keySettingsVersion = "settingsVersion"
private val separatorComma = ","
private val separatorUrl = "\n"
init {
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
// on the first launch instead of waiting up to 48 hours for the automatic update.
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
preferences.edit {
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
.forEach { key -> remove(key) }
putInt(keySettingsVersion, appVersionCode.toInt())
}
}
}
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
@@ -383,23 +399,23 @@ class SettingsRepo(
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
)
preferences.edit {
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
}
_dataSourcesSettings.value = normalized
}
private fun getDataSourcesSettings(): DataSourcesSettings {
val (satUrls, satEnabled) = parseSources(
preferences.getString(keySatelliteUrls, null),
preferences.getString(keySatelliteEnabled, null),
preferences.getString(keySatellitesUrls, null),
preferences.getString(keySatellitesUrlsEnabled, null),
Sources.satelliteDataUrls
)
val (txUrls, txEnabled) = parseSources(
preferences.getString(keyTransceiversUrls, null),
preferences.getString(keyTransceiversEnabled, null),
preferences.getString(keyTransceiversUrlsEnabled, null),
Sources.transceiversDataUrls
)
return DataSourcesSettings(
@@ -504,4 +520,16 @@ class SettingsRepo(
}
preferences.edit { putString(keySatelliteOffsets, updated) }
}
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
override fun getAmSatCallsign(): String {
return preferences.getString(keyAmSatCallsign, "").orEmpty()
}
override fun setAmSatCallsign(callsign: String) {
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
}
//endregion
}
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return selectedOrbitalObjects
}
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
override suspend fun renameEntries(names: Map<Int, String>) =
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
)
private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
)
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
@@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios()
}
override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.insertRadios(radios.toFrameworkRadios())
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
}
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio(
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
)
private fun FrameworkRadio.toDomain() = DomainRadio(
@@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
)
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
//endregion
@@ -23,8 +23,10 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
class RemoteSource(
@@ -97,4 +99,21 @@ class RemoteSource(
null
}
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
try {
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php")
.header("User-Agent", "Look4Sat")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
response.code to response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource submitAmSatReport exception: $exception")
null
}
}
}
@@ -42,6 +42,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest {
@@ -49,6 +50,10 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher)
// fixtures carry a current epoch: stale entries are pruned on every remote update
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
@Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites"
@@ -102,18 +107,176 @@ class DatabaseRepoTest {
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
}
@Test
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
val primaryUrl = "https://example.com/primary.txt"
val secondaryUrl = "https://example.com/secondary.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[primaryUrl] = { validTleStream() }
networkStreams[secondaryUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(primaryUrl, secondaryUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
val freshUri = "content://look4sat/import/fresh"
val staleUri = "content://look4sat/import/stale"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[freshUri] = { fresherTleStream() }
fileStreams[staleUri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
assertEquals(1, repository.updateTLEFromFile(freshUri))
assertEquals(1, repository.updateTLEFromFile(staleUri))
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
}
@Test
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
repository.updateFromRemote()
assertEquals(2, localSource.insertedRadios.size)
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
repository.updateFromRemote()
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
}
@Test
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
val preferredUrl = "https://example.com/preferred.txt"
val freshUrl = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[freshUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(freshUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals("ISS", localSource.insertedEntries.single().name)
// the user moves a source with older elements, but a better name, to the top of the list
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
satelliteUrls = listOf(preferredUrl, freshUrl),
transceiversUrls = emptyList()
)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single()
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
val failingUrl = "https://example.com/offline.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(failingUrl),
transceiversUrls = emptyList()
)
)
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// the update timestamp stays put so the data is not presented as fresh, while the recorded
// attempt keeps the next app launch from hammering a source that is already failing
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
}
@Test
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
val radioUri = "content://imported.json"
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[radioUri] = { customRadioStream() }
networkStreams[radioUrl] = { singleRadioStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTransceiversFromFile(radioUri)
repository.updateFromRemote()
// the remote snapshot replaces what the sources publish, the imported entry stays put
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
}
private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun fresherTleStream(): InputStream = """
ISS
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun twoRadiosStream(): InputStream = """
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream()
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
private fun customRadioStream(): InputStream = """
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream()
}
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
@@ -128,11 +291,14 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
private class FakeLocalSource : ILocalSource {
val insertedEntries = mutableListOf<OrbitalData>()
private val insertedRadios = mutableListOf<SatRadio>()
val insertedRadios = mutableListOf<SatRadio>()
private val customRadios = mutableListOf<String>()
override suspend fun getEntriesTotal(): Int = insertedEntries.size
@@ -140,6 +306,21 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> =
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
override suspend fun getEntriesNames(): Map<Int, String> =
insertedEntries.associate { entry -> entry.catnum to entry.name }
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
}
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
insertedEntries.removeAll { entry -> entry.catnum in ids }
}
override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries
}
@@ -154,18 +335,27 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) {
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
insertedRadios += radios
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
}
override suspend fun deleteRadios() {
insertedRadios.clear()
customRadios.clear()
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
}
}
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
@@ -229,6 +419,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -84,6 +84,65 @@ class SelectionRepoTest {
assertEquals(listOf(true, false, false), items.map { it.isSelected })
}
@Test
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("ao7")
val items = flow.first()
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
// guarantee is that AO-7 — unreachable before because of dashes/brackets
// — is now found.
assertEquals(7530, items.first().catnum)
assertEquals(listOf(7530, 39444), items.map { it.catnum })
}
@Test
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("fo29")
assertEquals(listOf(99999), flow.first().map { it.catnum })
}
@Test
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zarya iss")
assertEquals(listOf(25544), flow.first().map { it.catnum })
}
@Test
fun `partial token query matches substring`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("funcube")
assertEquals(listOf(39444), flow.first().map { it.catnum })
}
@Test
fun `unmatched query returns nothing`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zzzznomatch")
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
}
private fun repoWithSearchEntries(): SelectionRepo {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
SatItem(39444, "AO-73 (FUNcube-1)", false),
SatItem(43803, "JO-97 (BIRDS-3)", false),
SatItem(99999, "FO-29", false)
)
)
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
}
private class FakeLocalSource(
private val entries: List<SatItem>
) : ILocalSource {
@@ -93,6 +152,14 @@ class SelectionRepoTest {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
override suspend fun renameEntries(names: Map<Int, String>) = Unit
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
@@ -103,9 +170,11 @@ class SelectionRepoTest {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
override suspend fun deleteRadios() = Unit
override suspend fun deleteManagedRadios() = Unit
}
private class FakeSettingsRepo(
@@ -113,6 +182,7 @@ class SelectionRepoTest {
) : ISettingsRepo {
override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
@@ -175,7 +245,9 @@ class SelectionRepoTest {
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
}
}
@@ -0,0 +1,17 @@
package com.rtbishop.look4sat.core.domain.model
/** One public AMSAT satellite status report submission. */
data class AmSatReportSubmission(
val name: String,
val report: String,
val callsign: String,
val gridSquare: String,
val reportedAtUtcMillis: Long
)
/** Result of submitting an AMSAT satellite status report. */
data class AmSatReportSubmitResult(
val success: Boolean,
val message: String = "",
val reportId: String? = null
)
@@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
* missing optional field should not cost the whole transceiver. */
@Serializable
data class SatRadio(
@SerialName("uuid") val uuid: String,
@SerialName("description") val info: String,
@SerialName("alive") val isAlive: Boolean,
@SerialName("downlink_low") val downlinkLow: Long?,
@SerialName("downlink_high") val downlinkHigh: Long?,
@SerialName("mode") val downlinkMode: String?,
@SerialName("uplink_low") val uplinkLow: Long?,
@SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?
@SerialName("description") val info: String = "",
@SerialName("alive") val isAlive: Boolean = true,
@SerialName("downlink_low") val downlinkLow: Long? = null,
@SerialName("downlink_high") val downlinkHigh: Long? = null,
@SerialName("mode") val downlinkMode: String? = null,
@SerialName("uplink_low") val uplinkLow: Long? = null,
@SerialName("uplink_high") val uplinkHigh: Long? = null,
@SerialName("uplink_mode") val uplinkMode: String? = null,
@SerialName("invert") val isInverted: Boolean = false,
@SerialName("norad_cat_id") val catnum: Int? = null
)
@@ -37,30 +37,42 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
/** Absolute age of this element set, used to pick the freshest data among several sources. */
val epochDaynum: Double = epochToDaynum(epoch)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
}
private fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
companion object {
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
private fun 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
/**
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
* freshness checks must always go through this conversion.
*/
fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
}
}
@@ -1,9 +1,14 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
}
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
interface ISettingsRepo {
val appVersionName: String
val appVersionCode: Long
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
@@ -85,4 +86,9 @@ interface ISettingsRepo {
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
}
@@ -26,11 +26,18 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
suspend fun getEntriesEpochs(): Map<Int, Double>
suspend fun getEntriesNames(): Map<Int, String>
suspend fun renameEntries(names: Map<Int, String>)
suspend fun deleteEntriesWithIds(ids: List<Int>)
suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>)
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
suspend fun deleteRadios()
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
suspend fun deleteManagedRadios()
}
@@ -32,4 +32,5 @@ interface IRemoteSource {
suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
}
@@ -18,17 +18,19 @@
package com.rtbishop.look4sat.core.domain.source
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(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"db.satnogs.org/api/tle/?format=3le",
"amsat.org/tle/current/nasabare.txt",
"mmccants.org/tles/classfd.zip",
"live.ariss.org/iss.txt",
"r4uab.ru/satonline.txt",
"live.ariss.org/iss.txt"
"mmccants.org/tles/classfd.zip",
"amsat.org/tle/current/nasabare.txt",
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"db.satnogs.org/api/tle/?format=3le"
)
val transceiversDataUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active",
"r4uab.ru/transmitters.json"
"r4uab.ru/transmitters.json",
"db.satnogs.org/api/transmitters/?format=json&status=active"
)
val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
@@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
private val celestrakCSVColumns = mapOf(
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
)
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines ->
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
val iterator = lines.iterator()
if (!iterator.hasNext()) return@useLines emptyList()
val columns = parseCSVColumns(iterator.next())
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
}
}
@@ -58,35 +67,63 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList())
}
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val timestamp = values[2]
val year = timestamp.substring(0, 4)
val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt()
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
val day = dayInt.toString().padStart(3, '0')
val hour = timestamp.substring(11, 13).toInt() * 3600000
val min = timestamp.substring(14, 16).toInt() * 60000
val sec = timestamp.substring(17, 19).toInt() * 1000
val ms = timestamp.substring(20, 26).toInt() / 1000.0
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
fun value(column: String) = values[columns.getValue(column)].trim()
fun optionalValue(column: String) =
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
OrbitalData(
name = name,
epoch = epoch,
meanmo = values[3].toDouble(),
eccn = values[4].toDouble(),
incl = values[5].toDouble(),
raan = values[6].toDouble(),
argper = values[7].toDouble(),
meanan = values[8].toDouble(),
catnum = values[11].toInt(),
bstar = values[14].toDouble(),
ndot = values[15].toDouble()
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 dayOfMonth = timestamp.substring(8, 10).toInt()
val hours = timestamp.substring(11, 13).toInt()
val minutes = timestamp.substring(14, 16).toInt()
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble()
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction
}
/**
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5:
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0.
*/
private fun parseCatnum(value: String): Int {
val catnum = value.trim()
if (catnum.first().isDigit()) return catnum.toInt()
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar())
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" }
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt()
}
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
val line1 = tle[1]
val line2 = tle[2]
@@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
raan = line2.substring(17, 25).toDouble(),
argper = line2.substring(34, 42).toDouble(),
meanan = line2.substring(43, 51).toDouble(),
catnum = line1.substring(2, 7).trim().toInt(),
catnum = parseCatnum(line1.substring(2, 7)),
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
ndot = line1.substring(33, 43).trim().toDouble()
)
@@ -23,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.math.abs
@ExperimentalCoroutinesApi
class DataParserTest {
@@ -61,8 +62,49 @@ class DataParserTest {
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream)
assert(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816)
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8)
}
@Test
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
val csvStream = """
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
""".trimIndent().byteInputStream()
val sat = dataParser.parseCSVStream(csvStream)[0]
assert(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035)
assert(sat.bstar == 0.31985E-4)
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
}
@Test
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
val csvStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
val parsedList = dataParser.parseCSVStream(csvStream)
assert(parsedList.size == 2)
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
}
@Test
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
val tleStream = """
NAVSTAR 43
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
val sat = dataParser.parseTLEStream(tleStream)[0]
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
assert(sat.catnum == 270111)
}
@Test
@@ -52,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
@@ -305,6 +305,7 @@ fun InfoDialog(
title: String,
onDismiss: () -> Unit,
onAccept: () -> Unit,
extraAction: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onDismiss) { padding ->
@@ -323,6 +324,10 @@ fun InfoDialog(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
extraAction?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
@@ -425,11 +430,11 @@ private fun DialogShell(
@Composable
fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
@Composable
fun hasEnoughWidth(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
@Composable
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
@@ -0,0 +1,272 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
//region DragDrop Region
@Composable
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
offset.y.toInt() in item.offset..(item.offset + item.size)
}?.also {
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f, spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index
}
if (targetItem != null) {
if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) {
state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset)
}
onMove.invoke(draggingItem.index, targetItem.index)
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 -> (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val zIndexMod = Modifier.zIndex(1f)
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier = when {
dragging -> zIndexMod.graphicsLayer { translationY = dragDropState.draggingItemOffset }
index == dragDropState.previousIndexOfDraggedItem -> {
zIndexMod.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
}
else -> Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}
//endregion
//region Swipe Region
@Composable
fun SwipeableItem(onRemove: () -> Unit, content: @Composable () -> Unit) {
val coroutineScope = rememberCoroutineScope()
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val isVisible = remember { mutableStateOf(true) }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
hapticFeedback.performHapticFeedback(feedbackType)
}
AnimatedVisibility(visible = isVisible.value, exit = fadeOut(spring())) {
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = false,
backgroundContent = { DismissBackground(willTrigger) },
content = { content() },
onDismiss = {
isVisible.value = false
coroutineScope.launch { dismissState.reset() }
}
)
}
LaunchedEffect(isVisible.value) {
if (!isVisible.value) { delay(250.milliseconds).also { onRemove() } }
}
}
@Composable
fun DismissBackground(willTrigger: Boolean, bgColor: Color = Color(0xFFFF3C3A)) {
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxSize()
.border(width = 2.dp, shape = MaterialTheme.shapes.extraLarge, color = bgColor)
.dropShadow(shape = MaterialTheme.shapes.extraLarge) {
color = bgColor
radius = 40f
alpha = if (willTrigger) .2f else 0f
}
.innerShadow(shape = MaterialTheme.shapes.extraLarge) {
color = bgColor
radius = 40f
alpha = if (willTrigger) 1f else .2f
}
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.fillMaxHeight()
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
translationX = slide.toPx()
}
)
}
}
//endregion
@@ -0,0 +1,311 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
/**
* Long-press drag-to-reorder for rows inside a `LazyColumn`, with built-in edge auto-scroll and
* a settle-back animation when the finger is released.
*
* Reordering only ever swaps an item with its neighbor *inside the `items` list* passed to
* [rememberDragRowState] — so multiple independent groups of rows (e.g. two sections separated
* by header items) can share one [DragReorderState]/`LazyColumn` simply by giving each section
* its own backing list and calling [rememberDragRowState] with that section's list. Keys must be
* globally unique across the whole `LazyColumn` and match exactly what is passed as the
* `key = { ... }` lambda of `items`/`itemsIndexed`.
*
* Usage:
* ```
* val dragState = rememberDragReorderState(listState)
* itemsIndexed(entries, key = { _, e -> e.id }) { index, entry ->
* val row = rememberDragRowState(dragState, entries, entry, key = { it.id }) { from, to ->
* entries.add(to, entries.removeAt(from))
* }
* Row(
* modifier = Modifier
* .dragLift(row.isLifted, row.translationY)
* .animateItem(placementSpec = if (row.isDragging) tween(0) else spring())
* ) {
* Icon(modifier = Modifier.dragHandle(row), painter = ..., contentDescription = null)
* Text(entry.label)
* }
* }
* ```
*/
@Stable
class DragReorderState internal constructor(internal val listState: LazyListState) {
internal var draggedKey by mutableStateOf<Any?>(null)
}
@Composable
fun rememberDragReorderState(listState: LazyListState): DragReorderState =
remember(listState) { DragReorderState(listState) }
/** Used for the settle-back animation once the finger is released: quick and snappy. */
private val reorderSettleSpec = tween<Float>(durationMillis = 150, easing = FastOutSlowInEasing)
/**
* Per-row drag bookkeeping, obtained via [rememberDragRowState].
*
* [dragStartOffset] is this row's viewport offset captured once at drag start, [fingerOffset] is
* the raw accumulated finger travel (used for edge auto-scroll and swap detection). [translationY]
* is derived every frame from those two plus the row's *current* live layout offset, so it
* automatically stays correct across neighbor swaps and list auto-scroll without any manual
* offset-compensation math.
*/
@Stable
class DragRowState<T> internal constructor(
private val dragState: DragReorderState,
private val itemsState: State<List<T>>,
private val itemKey: Any,
private val key: (T) -> Any,
private val onMoveState: State<(Int, Int) -> Unit>,
private val scope: CoroutineScope
) {
internal val fingerOffset: MutableFloatState = mutableFloatStateOf(0f)
internal val dragStartOffset: MutableFloatState = mutableFloatStateOf(0f)
internal val startCenterY: MutableFloatState = mutableFloatStateOf(0f)
internal val settleAnim = Animatable(0f)
var isSettling: Boolean by mutableStateOf(false)
private set
val isDragging: Boolean get() = dragState.draggedKey == itemKey
val isLifted: Boolean get() = isDragging || isSettling
val translationY: Float
get() = when {
isSettling -> settleAnim.value
isDragging -> currentTranslation()
else -> 0f
}
/**
* How far this row must be pushed away from its *current* layout slot so it stays glued to
* the finger. Because [dragStartOffset] is fixed at drag start while the row's live layout
* offset moves as neighbors swap places or the list auto-scrolls, this difference naturally
* absorbs both effects with no extra bookkeeping.
*/
private fun currentTranslation(): Float {
val liveOffset = dragState.listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == itemKey }?.offset?.toFloat()
?: dragStartOffset.floatValue
return dragStartOffset.floatValue + fingerOffset.floatValue - liveOffset
}
internal fun onDragStart() {
isSettling = false
val layout = dragState.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey }
dragStartOffset.floatValue = (layout?.offset ?: 0).toFloat()
startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
fingerOffset.floatValue = 0f
dragState.draggedKey = itemKey
}
internal fun onDrag(deltaY: Float) {
if (dragState.draggedKey != itemKey) return
fingerOffset.floatValue += deltaY
reorderLive()
}
private fun reorderLive() {
val items = itemsState.value
val myIndex = items.indexOfFirst { key(it) == itemKey }
if (myIndex !in items.indices) return
val myCenter = startCenterY.floatValue + fingerOffset.floatValue
val visible = dragState.listState.layoutInfo.visibleItemsInfo
val onMove = onMoveState.value
// Dragging down: swap when the dragged center passes the next row's midpoint.
if (myIndex < items.lastIndex) {
val nextKey = key(items[myIndex + 1])
val next = visible.firstOrNull { it.key == nextKey }
if (next != null && myCenter > next.offset + next.size / 2f) {
onMove(myIndex, myIndex + 1)
return
}
}
// Dragging up: swap when the dragged center passes the previous row's midpoint.
if (myIndex > 0) {
val prevKey = key(items[myIndex - 1])
val prev = visible.firstOrNull { it.key == prevKey }
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
onMove(myIndex, myIndex - 1)
}
}
}
// Reset the drag bookkeeping and fly the lifted row back into its slot.
// All state resets happen inside the launched block so the settle animation takes over from
// the current visual position without a one-frame jump: isSettling is flipped to true
// (switching rendering to settleAnim, already snapped to the last offset) before the drag
// flags are cleared.
internal fun onDragEnd() {
val lastOffset = currentTranslation()
if (kotlin.math.abs(lastOffset) < 1f) {
fingerOffset.floatValue = 0f
dragState.draggedKey = null
return
}
scope.launch {
settleAnim.snapTo(lastOffset)
isSettling = true
fingerOffset.floatValue = 0f
dragState.draggedKey = null
settleAnim.animateTo(0f, reorderSettleSpec)
isSettling = false
}
}
}
/**
* Remembers a [DragRowState] for one row of a drag-reorderable list.
*
* [items] must be the exact (optionally section-scoped) list backing the enclosing
* `items`/`itemsIndexed` call, and [key] must return the same value used as that call's
* `key = { ... }` lambda. Reordering only ever swaps neighbors within [items], so passing a
* section-local list is what confines dragging to one section of a multi-section `LazyColumn`.
*/
@Composable
fun <T> rememberDragRowState(
dragState: DragReorderState,
items: List<T>,
item: T,
key: (T) -> Any,
onMove: (from: Int, to: Int) -> Unit
): DragRowState<T> {
val scope = rememberCoroutineScope()
val itemsState = rememberUpdatedState(items)
val onMoveState = rememberUpdatedState(onMove)
val itemKey = key(item)
val rowState = remember(dragState, itemKey) {
DragRowState(dragState, itemsState, itemKey, key, onMoveState, scope)
}
LaunchedEffect(rowState.isDragging) {
if (!rowState.isDragging) return@LaunchedEffect
autoScrollWhileDragging(dragState.listState, rowState.startCenterY, rowState.fingerOffset)
}
return rowState
}
/** Drag-handle gesture: attach to a small handle icon to start/drive/end reordering of [rowState]'s row. */
fun Modifier.dragHandle(rowState: DragRowState<*>): Modifier = pointerInput(rowState) {
detectDragGesturesAfterLongPress(
onDragStart = { rowState.onDragStart() },
onDragEnd = { rowState.onDragEnd() },
onDragCancel = { rowState.onDragEnd() }
) { change, dragAmount ->
change.consume()
rowState.onDrag(dragAmount.y)
}
}
/**
* Visual treatment for a draggable row: while [isLifted], translates the row by [translationY],
* scales it up slightly and raises it above its neighbors with a shadow and a solid background so
* it fully covers the row beneath instead of showing a translucent overlap of both rows.
*/
@Composable
fun Modifier.dragLift(isLifted: Boolean, translationY: Float): Modifier {
val shape = MaterialTheme.shapes.small
val scale by animateFloatAsState(
targetValue = if (isLifted) 1.02f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "dragScale"
)
return this
.graphicsLayer {
if (isLifted) {
this.translationY = translationY
scaleX = scale
scaleY = scale
}
}
.then(
if (isLifted) {
Modifier
.zIndex(1f)
.shadow(8.dp, shape, clip = false)
.background(MaterialTheme.colorScheme.surface, shape)
} else {
Modifier
}
)
}
/**
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
* The visual center is tracked independently of the entry's layout slot (which can scroll out of
* `LazyListState.layoutInfo.visibleItemsInfo` during a long drag); [startCenterY] is the entry's
* viewport center captured at drag start and [fingerOffset] is the raw finger delta. The
* resulting scroll is picked up automatically by [DragRowState.translationY] on the next frame,
* so no separate scroll-compensation bookkeeping is required here.
*/
private suspend fun autoScrollWhileDragging(
listState: LazyListState,
startCenterY: MutableFloatState,
fingerOffset: MutableFloatState
) {
val threshold = 48f
val maxSpeed = 24f
while (true) {
val info = listState.layoutInfo
val center = startCenterY.floatValue + fingerOffset.floatValue
val top = info.viewportStartOffset + threshold
val bottom = info.viewportEndOffset - threshold
val delta = when {
center < top -> -(top - center).coerceAtMost(maxSpeed)
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
else -> 0f
}
if (delta != 0f) listState.scrollBy(delta)
delay(16L.milliseconds)
}
}
@@ -46,7 +46,7 @@ data object Spacing {
val extraSmall = 6.dp
val small = 8.dp
val medium = 12.dp
val large = 16.dp
val large = 12.dp
val extraLarge = 24.dp
}
@@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
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" />
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,840L240,760Q240,760 256.5,750Q273,740 292.5,720.5Q312,701 328,670.5Q344,640 344,600Q344,589 342.5,579Q341,569 339,560L240,560L240,480L300,480Q279,447 259.5,410.5Q240,374 240,320Q240,228 304,164Q368,100 460,100Q531,100 586,139Q641,178 665,240L591,271Q576,231 540.5,205.5Q505,180 460,180Q402,180 361,221Q320,262 320,320Q320,368 344,400Q368,432 393,480L560,480L560,560L421,560Q423,569 423.5,579Q424,589 424,600Q424,650 406.5,690Q389,730 364,760L560,760Q600,760 621,739Q642,718 650,685L720,720Q709,775 663.5,807.5Q618,840 560,840L240,840Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
</vector>
@@ -121,6 +121,8 @@
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -120,6 +120,8 @@
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
<string name="prefs_data_sources_sat_title">Данные спутников</string>
<string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -121,6 +121,8 @@
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න</string>
<string name="prefs_data_sources_sat_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_sources_transceivers_title">සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_sat_hint">ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම්</string>
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්‍රමුඛතාව: සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -152,6 +152,8 @@
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
<string name="prefs_data_sources_sat_title">Uydu verileri</string>
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -121,6 +121,8 @@
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
<string name="prefs_data_sources_sat_title">Дані супутників</string>
<string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -30,6 +30,26 @@
<string name="amsat_status_tlm_only">仅遥测</string>
<string name="amsat_status_not_heard">未听到</string>
<string name="amsat_status_crew_active">乘组活跃</string>
<string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_confirm">确认上传</string>
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
<string name="amsat_upload_grid_none">未填写</string>
<string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string>
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
<string name="amsat_upload_active">活跃</string>
<string name="amsat_upload_tlm">遥测/信标</string>
<string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Satellites screen -->
<string name="sat_type_hint">模式:%s</string>
@@ -83,6 +103,24 @@
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">连接</string>
<string name="radar_disconnect">断开连接</string>
<string name="radar_off">关</string>
<string name="radar_stop">停止</string>
<string name="radar_track">跟踪</string>
<string name="radar_tx_base">TX 基频:</string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">授予权限</string>
<string name="sstv_listening">正在监听…</string>
<string name="sstv_mic_permission">需要麦克风权限</string>
<string name="sstv_mode_format">模式:%s</string>
<string name="sstv_mode_title">SSTV 模式</string>
<string name="sstv_no_signal">无信号</string>
<string name="sstv_no_transceiver">未选择收发器</string>
<string name="sstv_rx_format">RX:%s Hz</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
<string name="map_next">下一个</string>
@@ -103,6 +141,12 @@
<string name="prefs_donate_title">支持</string>
<string name="prefs_privacy_title">隐私政策</string>
<string name="settings_paired_devices">已配对的蓝牙设备:</string>
<string name="settings_split_mode">分频模式(仅 IC-705)</string>
<string name="prefs_lat_prefix">纬度:%s°</string>
<string name="prefs_lon_prefix">经度:%s°</string>
<string name="prefs_qth_prefix">QTH:%s</string>
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
@@ -135,6 +179,8 @@
<string name="prefs_data_sources_restore">恢复默认源</string>
<string name="prefs_data_sources_sat_title">卫星数据</string>
<string name="prefs_data_sources_transceivers_title">收发器数据</string>
<string name="prefs_data_sources_sat_hint">靠前优先:卫星名称</string>
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -195,4 +241,7 @@
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- Main screen -->
<string name="tracking_format">跟踪:%s</string>
</resources>
@@ -56,13 +56,14 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Added AMSAT status tracking page by MCKero6423 (#234)
\n* Tweaked Radar track on reposition by atsunatsu (#235)
\n* Tweaked linear transponder calc by atsunatsu (#236)
\n* Added saving per-sat doppler offset by atsunatsu (#237)
\n* Added custom freq offset setting to network reporting
\n* Added ability to customize data sources via import
\n* Fixed star chart in README by PingouinFerreux (#240)
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
\n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
\nSources now have priority - the top ones define satellite names and transceiver data.
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
</string>
<!-- AMSAT Status screen -->
@@ -83,6 +84,26 @@
<string name="amsat_status_tlm_only">Telemetry Only</string>
<string name="amsat_status_not_heard">Not Heard</string>
<string name="amsat_status_crew_active">Crew Active</string>
<string name="amsat_upload">Upload</string>
<string name="amsat_upload_back">Back</string>
<string name="amsat_upload_report">Upload report to AMSAT</string>
<string name="amsat_upload_status">Status</string>
<string name="amsat_upload_callsign">Callsign</string>
<string name="amsat_upload_grid">Grid</string>
<string name="amsat_upload_time_now">Time: current UTC %s</string>
<string name="amsat_upload_submit">Submit to AMSAT</string>
<string name="amsat_upload_confirm">Upload</string>
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
<string name="amsat_upload_grid_none">Not provided</string>
<string name="amsat_upload_success">Report uploaded</string>
<string name="amsat_upload_failed">Upload failed: %s</string>
<string name="amsat_upload_callsign_required">Callsign is required</string>
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
<string name="amsat_upload_active">Active</string>
<string name="amsat_upload_tlm">TLM/Beacon</string>
<string name="amsat_upload_not_heard">Not Heard</string>
<string name="amsat_upload_crew_active">Crew Active</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
@@ -113,6 +134,24 @@
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">Connect</string>
<string name="radar_disconnect">Disconnect</string>
<string name="radar_off">Off</string>
<string name="radar_stop">Stop</string>
<string name="radar_track">Track</string>
<string name="radar_tx_base">TX Base: </string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">Grant permission</string>
<string name="sstv_listening">Listening…</string>
<string name="sstv_mic_permission">Microphone access needed</string>
<string name="sstv_mode_format">Mode: %s</string>
<string name="sstv_mode_title">SSTV Mode</string>
<string name="sstv_no_signal">No signal</string>
<string name="sstv_no_transceiver">No transceiver selected</string>
<string name="sstv_rx_format">RX: %s Hz</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
<string name="map_next">Next</string>
@@ -145,6 +184,12 @@
<string name="prefs_privacy_title">Privacy Policy</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="settings_paired_devices">Paired Bluetooth Devices:</string>
<string name="settings_split_mode">Split mode (IC-705 only)</string>
<string name="prefs_lat_prefix">Lat: %s°</string>
<string name="prefs_lon_prefix">Lon: %s°</string>
<string name="prefs_qth_prefix">Qth: %s</string>
<string name="prefs_updated_title">Updated: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -177,6 +222,8 @@
<string name="prefs_data_sources_restore">Restore default sources</string>
<string name="prefs_data_sources_sat_title">Satellites data</string>
<string name="prefs_data_sources_transceivers_title">Transceivers data</string>
<string name="prefs_data_sources_sat_hint">Top priority affects satellite names</string>
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
@@ -239,4 +286,7 @@
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
<!-- Main screen -->
<string name="tracking_format">Tracking: %s</string>
</resources>
@@ -1,7 +1,5 @@
* Added AMSAT status tracking page by MCKero6423 (#234)
* Tweaked Radar track on reposition by atsunatsu (#235)
* Tweaked linear transponder calc by atsunatsu (#236)
* Added saving per-sat doppler offset by atsunatsu (#237)
* Added custom freq offset setting to network reporting
* Added ability to customize data sources via import
* Fixed star chart in README by PingouinFerreux (#240)
* Tweaked sun calculations, added tests by atsunatsu (#241)
* Overhauled sources import, added HTTP status by wty2019wty (#242)
* Localized AMSAT status screen for Chinese by atsunatsu (#245)
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
* Added AMSAT satellite status reports submission by atsunatsu (#248)
@@ -21,6 +21,7 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay
import kotlin.math.cos
@@ -39,9 +40,11 @@ import kotlin.math.sin
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
*
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
* Performance: we sample one column per stepPx pixels (default 4) and draw
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
* evaluations per row, which is imperceptible.
* evaluations per row, which is imperceptible. draw() is called on every
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
* single GeoPoint instance.
*/
class MapNightOverlay : Overlay() {
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
private val rect = RectF()
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
val h = mapView.height
val stepPx = 4 // sample every N pixels — balance quality vs CPU
// The map is never rotated, so latitude depends only on y and longitude only on x.
// Resolve the top/bottom latitudes once instead of once per column.
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
val sinLatTop = sin(latTopRad)
val cosLatTop = cos(latTopRad)
val sinLatBot = sin(latBotRad)
val cosLatBot = cos(latBotRad)
// We scan column by column. For each column we determine the longitude,
// then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
var x = 0
while (x < w) {
// 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 geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
val lonRad = Math.toRadians(geoTop.longitude)
val cosLonDiff = cos(lonRad - sunLonRad)
// Top pixel geographic lat
val latTopRad = Math.toRadians(geoTop.latitude)
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
val latBotRad = Math.toRadians(geoBot.latitude)
// dot(sunVec, pointVec) < 0 → night
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
when {
dotTop < 0 && dotBot < 0 -> {
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
var hi = yBot
while (hi - lo > 1) {
val mid = (lo + hi) / 2
val geo = proj.fromPixels(x, mid) ?: return mid
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
val latRad = Math.toRadians(geo.latitude)
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow
@@ -71,6 +70,10 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
@@ -126,6 +129,21 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView)
}
@@ -283,19 +301,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private val markerPool = HashMap<String, Marker>()
private var lastMapView: MapView? = null
/**
* Above this many satellites inside the viewport labels are dropped in favor of a single
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
*/
private const val LABEL_LIMIT = 128
/** Degrees of space around the viewport so markers don't pop in at the edges */
private const val VIEWPORT_MARGIN = 8.0
/** Debounce for viewport-driven marker refreshes, in milliseconds */
private const val MAP_LISTENER_DELAY = 128L
/** Shared dot icon used when too many satellites are visible to label them */
private var dotIcon: Drawable? = null
/** Scratch list reused every frame to avoid per-tick allocation */
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
private var lastAction: ((OrbitalObject) -> Unit)? = null
private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView,
action: (OrbitalObject) -> Unit
) {
try {
lastPositions = posMap
lastAction = action
// Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) {
lastMapView = mapView
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
mapView.addMapListener(DelayedMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
}, MAP_LISTENER_DELAY))
}
// Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible.
@@ -304,17 +352,48 @@ private fun setPositions(
}
folder.items.clear()
val activeNames = HashSet<String>(posMap.size)
posMap.forEach { (satellite, geoPos) ->
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
// exactly when marker labels are shown and drawing is most expensive.
visibleSats.clear()
if (mapView.width > 0 && mapView.height > 0) {
val box = mapView.boundingBox
val latNorth = box.latNorth + VIEWPORT_MARGIN
val latSouth = box.latSouth - VIEWPORT_MARGIN
val lonWest = box.lonWest - VIEWPORT_MARGIN
val lonEast = box.lonEast + VIEWPORT_MARGIN
val wrapsDateLine = box.lonWest > box.lonEast
for ((satellite, geoPos) in posMap) {
val lat = geoPos.latitude
if (lat !in latSouth..latNorth) continue
val lon = geoPos.longitude
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
else lon in lonWest..lonEast
if (isLonVisible) visibleSats.add(satellite to geoPos)
}
} else {
for (entry in posMap) visibleSats.add(entry.key to entry.value)
}
val showLabels = visibleSats.size <= LABEL_LIMIT
val activeNames = HashSet<String>(visibleSats.size)
for ((satellite, geoPos) in visibleSats) {
val name = satellite.data.name
activeNames.add(name)
val marker = markerPool.getOrPut(name) {
Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCachedTextIcon(name, mapView)
// Resolve the satellite via relatedObject so the listener is allocated
// once per marker instead of once per satellite per tick
setOnMarkerClickListener { clicked, _ ->
(clicked.relatedObject as? OrbitalObject)?.let(action)
true
}
}
}
marker.relatedObject = satellite
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
if (marker.icon !== icon) marker.icon = icon
// Update position in-place — reuse existing GeoPoint if available
val pos = marker.position
if (pos != null) {
@@ -323,22 +402,32 @@ private fun setPositions(
} else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
}
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker)
}
// Evict markers for satellites no longer tracked
val iter = markerPool.keys.iterator()
while (iter.hasNext()) {
if (iter.next() !in activeNames) iter.remove()
}
// Evict markers that are no longer tracked or no longer visible
markerPool.keys.retainAll(activeNames)
visibleSats.clear()
} catch (e: Exception) {
println(e)
}
}
/** Re-applies the last known positions against the new viewport after a pan or zoom */
private fun refreshPositions(mapView: MapView): Boolean {
val posMap = lastPositions ?: return false
val action = lastAction ?: return false
setPositions(posMap, mapView, action)
mapView.invalidate()
return true
}
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
val size = 20
val bitmap = createBitmap(size, size)
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
}
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
iconCache[name]?.let { return it }
val labelRect = Rect()
@@ -376,9 +465,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
try {
val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size)
@@ -508,9 +596,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(lifecycleObserver)
onDispose { lifecycle.removeObserver(lifecycleObserver) }
}
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
DisposableEffect(mapView) {
onDispose {
clearMapCaches()
mapView.onDetach()
}
}
return mapView
}
private fun clearMapCaches() {
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
lastPositions = null
lastAction = null
lastMapView = null
}
@Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event ->
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.feature.map
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState(
val mapData: MapData? = null,
@@ -29,7 +28,7 @@ data class MapState(
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null,
val footprint: List<GeoPos>? = null,
val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0,
@@ -42,6 +41,7 @@ sealed interface MapAction {
data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
data class MapData(
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
class MapViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -69,6 +72,9 @@ class MapViewModel(
private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L
private var selectedOrbitalObject: OrbitalObject? = null
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
private val isScreenVisible = MutableStateFlow(true)
val uiState: StateFlow<MapState> = _uiState
init {
@@ -87,6 +93,7 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
@@ -124,7 +131,11 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject
viewModelScope.launch {
dataUpdateJob?.cancelAndJoin()
dataUpdateJob = launch {
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
// satelliteRepo call internally hops to Default and resumes back on the caller's
// dispatcher. On Main that posts one continuation per satellite per tick, which
// floods the looper and ANRs for users tracking thousands of objects.
dataUpdateJob = launch(Dispatchers.Default) {
val dateNow = Date()
getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow)
@@ -135,9 +146,11 @@ class MapViewModel(
else -> updateFreq
}
while (isActive) {
// Suspends while the map is off-screen instead of predicting into the void
isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(effectiveRate)
delay(effectiveRate.milliseconds)
}
}
}
@@ -187,7 +200,8 @@ class MapViewModel(
// 2. Derive footprint, info data, sun and moon position from already-computed state
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
val footprint = satPos
// Range circle is 721 trig-heavy points — keep it on this background dispatcher
val footprint = satPos.getRangeCircle()
val mapData = buildMapData(selected, satPos, date)
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
@@ -294,7 +308,7 @@ class MapViewModel(
companion object {
/** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4
private val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
@@ -31,9 +31,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -115,6 +118,7 @@ private fun RadarScreen(
Column(
modifier = Modifier
.layoutPadding()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
@@ -49,6 +49,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -78,12 +79,12 @@ internal fun SstvPage(
) {
Text(text = "🎙️", fontSize = 48.sp)
Text(
text = "Microphone access needed",
text = stringResource(R.string.sstv_mic_permission),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
)
Button(onClick = requestMicPermission) {
Text("Grant permission")
Text(stringResource(R.string.sstv_grant_permission))
}
}
}
@@ -97,7 +98,7 @@ internal fun SstvPage(
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
val dismiss = { showModeDialog.value = false }
ConfirmDialog(
title = "SSTV Mode",
title = stringResource(R.string.sstv_mode_title),
onAccept = dismiss,
onCancel = dismiss
) {
@@ -167,7 +168,7 @@ internal fun SstvPage(
)
} else {
Text(
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
text = if (sstv.status == SstvStatus.Recording) stringResource(R.string.sstv_listening) else stringResource(R.string.sstv_no_signal),
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center)
@@ -196,7 +197,7 @@ internal fun SstvPage(
) {
// Doppler-corrected downlink frequency hint
OutlinedText(
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
text = dopplerFrequency?.let { stringResource(R.string.sstv_rx_format, it) } ?: stringResource(R.string.sstv_no_transceiver),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fillColor = MaterialTheme.colorScheme.primary,
@@ -227,7 +228,7 @@ internal fun SstvPage(
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Mode: ${sstv.selectedMode}",
text = stringResource(R.string.sstv_mode_format, sstv.selectedMode),
fontSize = 14.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface,
@@ -403,7 +403,7 @@ private fun ExpandedRadioControl(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "TX Base: ",
text = stringResource(R.string.radar_tx_base),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -460,7 +460,7 @@ private fun ExpandedRadioControl(
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
label = {
Text(
text = "Off",
text = stringResource(R.string.radar_off),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
@@ -492,19 +492,19 @@ private fun ExpandedRadioControl(
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
CardButton(
onClick = { onAction(RadarAction.ConnectRadios) },
text = "Connect",
text = stringResource(R.string.radar_connect),
modifier = Modifier.weight(1f)
)
} else {
CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) },
text = "Disconnect",
text = stringResource(R.string.radar_disconnect),
modifier = Modifier.weight(1f)
)
}
CardButton(
onClick = { onAction(RadarAction.ToggleTracking) },
text = if (radioControl.isTracking) "Stop" else "Track",
text = if (radioControl.isTracking) stringResource(R.string.radar_stop) else stringResource(R.string.radar_track),
modifier = Modifier.weight(1f)
)
}
@@ -726,7 +726,7 @@ private fun DopplerFrequencyCalculator(
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
text = stringResource(R.string.radar_doppler_offset_hint),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -17,17 +17,10 @@
*/
package com.rtbishop.look4sat.feature.settings
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.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -37,10 +30,9 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Checkbox
@@ -51,50 +43,40 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
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.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
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.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.DragReorderState
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.dragHandle
import com.rtbishop.look4sat.core.presentation.dragLift
import com.rtbishop.look4sat.core.presentation.rememberDragReorderState
import com.rtbishop.look4sat.core.presentation.rememberDragRowState
@Preview(showBackground = true)
@Composable
@@ -195,6 +177,13 @@ private fun TransceiversDialogPreview() {
}
}
/**
* Single source of truth for one data-source row: url, enabled flag and stable id all live
* together so a reorder, a toggle or an edit is a single list mutation instead of several parallel
* lists/maps having to stay in sync (which used to fan out into extra recompositions).
*/
private data class SourceEntry(val id: Long, val url: String, val enabled: Boolean = true)
@Composable
fun DataSourcesDialog(
satelliteUrls: List<String>,
@@ -210,55 +199,46 @@ fun DataSourcesDialog(
val padding = LocalSpacing.current.large
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satUrls = remember {
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
val satEntries = remember {
satelliteUrls.mapIndexed { i, url ->
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
}.toMutableStateList()
}
val txUrls = remember {
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.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 txEntries = remember {
transceiversUrls.mapIndexed { i, url ->
SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true })
}.toMutableStateList()
}
val listState = rememberLazyListState()
val satDraggedId = remember { mutableStateOf(-1L) }
val txDraggedId = remember { mutableStateOf(-1L) }
val onRestoreDefaults = {
nextId.longValue = (Sources.satelliteDataUrls.size + Sources.transceiversDataUrls.size).toLong()
satUrls.clear()
satUrls.addAll(Sources.satelliteDataUrls.mapIndexed { i, url -> i.toLong() to url })
txUrls.clear()
txUrls.addAll(
Sources.transceiversDataUrls.mapIndexed { i, url ->
(Sources.satelliteDataUrls.size + i).toLong() to url
}
)
satEnabled.clear()
txEnabled.clear()
val dragState = rememberDragReorderState(listState)
val onRestoreSatDefaults = {
satEntries.clear()
satEntries.addAll(Sources.satelliteDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
Unit
}
val onRestoreTxDefaults = {
txEntries.clear()
txEntries.addAll(Sources.transceiversDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
Unit
}
val onAccept = {
val satFiltered = satUrls.filter { it.second.isNotBlank() }
val txFiltered = txUrls.filter { it.second.isNotBlank() }
val satFiltered = satEntries.filter { it.url.isNotBlank() }
val txFiltered = txEntries.filter { it.url.isNotBlank() }
try {
onSave(
satFiltered.map { it.second },
txFiltered.map { it.second },
satFiltered.map { satEnabled[it.first] ?: true },
txFiltered.map { txEnabled[it.first] ?: true }
satFiltered.map { it.url },
txFiltered.map { it.url },
satFiltered.map { it.enabled },
txFiltered.map { it.enabled }
)
} finally {
onDismiss()
}
}
val satTitle = stringResource(R.string.prefs_data_sources_sat_title)
val satHint = stringResource(R.string.prefs_data_sources_sat_hint)
val transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title)
val transceiversHint = stringResource(R.string.prefs_data_sources_transceivers_hint)
ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onDismiss,
@@ -270,7 +250,7 @@ fun DataSourcesDialog(
.fillMaxHeight(0.84f)
.padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 6.dp)
contentPadding = PaddingValues(vertical = 0.dp)
) {
item {
Row(
@@ -289,40 +269,33 @@ fun DataSourcesDialog(
)
}
}
item {
CardButton(
onClick = onRestoreDefaults,
text = stringResource(R.string.prefs_data_sources_restore),
modifier = Modifier.fillMaxWidth()
)
}
sourceSection(
sectionKey = "sat",
label = satTitle,
urls = satUrls,
listState = listState,
draggedId = satDraggedId,
hint = satHint,
entries = satEntries,
dragState = dragState,
statusCodes = statusCodes,
enabledMap = satEnabled,
onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) },
onAdd = { satUrls.add(nextId.longValue++ to "") },
onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) },
onRemove = { i -> satUrls.removeAt(i) },
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
onAdd = { satEntries.add(SourceEntry(nextId.longValue++, "")) },
onRestore = onRestoreSatDefaults,
onMove = { from, to -> satEntries.add(to, satEntries.removeAt(from)) },
onRemove = { i -> satEntries.removeAt(i) },
onToggle = { i -> satEntries[i] = satEntries[i].copy(enabled = !satEntries[i].enabled) },
onUrlChange = { i, v -> satEntries[i] = satEntries[i].copy(url = v) }
)
sourceSection(
sectionKey = "tx",
label = transceiversTitle,
urls = txUrls,
listState = listState,
draggedId = txDraggedId,
hint = transceiversHint,
entries = txEntries,
dragState = dragState,
statusCodes = statusCodes,
enabledMap = txEnabled,
onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) },
onAdd = { txUrls.add(nextId.longValue++ to "") },
onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) },
onRemove = { i -> txUrls.removeAt(i) },
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
onAdd = { txEntries.add(SourceEntry(nextId.longValue++, "")) },
onRestore = onRestoreTxDefaults,
onMove = { from, to -> txEntries.add(to, txEntries.removeAt(from)) },
onRemove = { i -> txEntries.removeAt(i) },
onToggle = { i -> txEntries[i] = txEntries[i].copy(enabled = !txEntries[i].enabled) },
onUrlChange = { i, v -> txEntries[i] = txEntries[i].copy(url = v) }
)
}
}
@@ -331,15 +304,15 @@ fun DataSourcesDialog(
private fun LazyListScope.sourceSection(
sectionKey: String,
label: String,
urls: List<Pair<Long, String>>,
listState: LazyListState,
draggedId: MutableState<Long>,
hint: String,
entries: SnapshotStateList<SourceEntry>,
dragState: DragReorderState,
statusCodes: Map<String, Int>,
enabledMap: Map<Long, Boolean>,
onToggle: (Long) -> Unit,
onAdd: () -> Unit,
onRestore: () -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
onToggle: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit
) {
item {
@@ -351,65 +324,71 @@ private fun LazyListScope.sourceSection(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
IconCard(
action = onRestore,
resId = R.drawable.ic_restore,
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
IconCard(
action = onAdd,
resId = R.drawable.ic_add,
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
}
Text(
text = hint,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp, bottom = 8.dp)
)
}
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
itemsIndexed(entries, key = { _, entry -> "$sectionKey-${entry.id}" }) { index, entry ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
val enabled = enabledMap[id] ?: true
val rowState = remember { DragRowState() }
val scope = rememberCoroutineScope()
val isDragging = draggedId.value == id
val isLifted = isDragging || rowState.isSettling.value
LaunchedEffect(isDragging) {
if (!isDragging) return@LaunchedEffect
autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp)
}
val rowState = rememberDragRowState(
dragState = dragState,
items = entries,
item = entry,
key = { "$sectionKey-${it.id}" },
onMove = onMove
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.draggedVisual(
isLifted = isLifted,
translationY = if (rowState.isSettling.value) {
rowState.settleAnim.value
} else {
rowState.offsetY.floatValue + rowState.scrollComp.floatValue
}
)
.dragLift(isLifted = rowState.isLifted, translationY = rowState.translationY)
.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)
fadeInSpec = tween(durationMillis = 200),
// The dragged row repositions instantly, while its neighbors smoothly
// slide out of the way (the "squeeze" effect).
placementSpec = if (rowState.isDragging) {
tween(durationMillis = 0)
} else {
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
tween(durationMillis = 250, easing = FastOutSlowInEasing)
},
fadeOutSpec = spring()
fadeOutSpec = tween(durationMillis = 200)
)
) {
Box(
contentAlignment = Alignment.Center,
Icon(
painter = painterResource(R.drawable.ic_drag_handle),
contentDescription = null,
tint = enabledTint,
modifier = Modifier
.size(40.dp)
.dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove)
) {
Icon(
painter = painterResource(R.drawable.ic_drag_handle),
contentDescription = null,
tint = enabledTint
)
}
.padding(top = textFieldLabelOffset)
.dragHandle(rowState)
)
Spacer(modifier = Modifier.width(12.dp))
OutlinedTextField(
value = url,
value = entry.url,
onValueChange = { onUrlChange(index, it) },
label = { Text("Source URL") },
supportingText = statusCodes[url]?.let { code ->
{ Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) }
label = {
Row {
Text("Source URL")
statusCodes[entry.url]?.let { code ->
Text(" - ${statusLabel(code)}", color = statusColor(code), fontSize = 12.sp)
}
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
@@ -420,198 +399,31 @@ private fun LazyListScope.sourceSection(
}
},
singleLine = true,
enabled = enabled,
enabled = entry.enabled,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = enabled,
onCheckedChange = { onToggle(id) }
checked = entry.enabled,
onCheckedChange = { onToggle(index) },
modifier = Modifier.padding(top = textFieldLabelOffset)
)
}
}
}
/**
* Per-row drag state, kept in one object to keep the drag-handle modifier signature small.
*
* [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.
* Compensates for the visual weight of the [OutlinedTextField]'s floating label: the label
* pushes the perceived center of the field's content down a bit, so the drag handle and
* checkbox are nudged down by the same amount to stay visually centered on the input line.
*/
private 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 val textFieldLabelOffset = 8.dp
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
@Composable
private fun statusColor(code: Int): Color = when {
code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
code in 200..299 -> Color(0xFF66BB6A)
private fun statusColor(code: Int): Color = when (code) {
NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
in 200..299 -> Color(0xFF66BB6A)
else -> MaterialTheme.colorScheme.error
}
@@ -980,7 +792,7 @@ fun RadioControlDialog(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Split mode (IC-705 only)",
text = stringResource(R.string.settings_split_mode),
fontWeight = FontWeight.Medium,
color = splitModeLabelColor
)
@@ -1020,7 +832,7 @@ fun RadioControlDialog(
if (selectingFor.value.isNotBlank()) {
Text(
text = "Paired Bluetooth Devices:",
text = stringResource(R.string.settings_paired_devices),
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
@@ -188,7 +188,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
// URLs for top bar
val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url)
// val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url)
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
@@ -209,7 +209,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
}
TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -231,7 +231,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
} else {
TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(
onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName,
@@ -342,9 +342,9 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°")
Text(text = "Qth: ${settings.stationPos.qthLocator}")
Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude))
Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude))
Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator))
}
Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -206,7 +206,6 @@ class SettingsViewModel(
}
}
// endregion
companion object {
-10
View File
@@ -1,17 +1,7 @@
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
@@ -1,5 +1,6 @@
package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -20,9 +21,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -51,15 +57,32 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp
private data class UploadOption(
val apiValue: String,
val labelResId: Int,
val color: Color
)
private val UPLOAD_OPTIONS = listOf(
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
)
/**
* Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
@@ -97,11 +120,33 @@ fun SatStatusDestination() {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SatStatusScreen(uiState) { viewModel.refresh() }
SatStatusScreen(
uiState = uiState,
refresh = viewModel::refresh,
onToggleUpload = viewModel::toggleUploadPanel,
onUploadReportChange = viewModel::setUploadReport,
onUploadCallsignChange = viewModel::setUploadCallsign,
onUploadGridChange = viewModel::setUploadGrid,
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
onDismissUpload = viewModel::collapseUploadPanel
)
}
@Composable
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
private fun SatStatusScreen(
uiState: SatStatusUiState,
refresh: () -> Unit,
onToggleUpload: () -> Unit,
onUploadReportChange: (String) -> Unit,
onUploadCallsignChange: (String) -> Unit,
onUploadGridChange: (String) -> Unit,
onRequestSubmitUpload: () -> Unit,
onConfirmSubmitUpload: (String) -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismissUpload: () -> Unit
) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
@@ -136,7 +181,23 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
}
selectedDay?.let { (status, day) ->
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
upload = uiState.upload,
onToggleUpload = onToggleUpload,
onReportChange = onUploadReportChange,
onCallsignChange = onUploadCallsignChange,
onGridChange = onUploadGridChange,
onRequestSubmitReport = onRequestSubmitUpload,
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
onDismiss = {
selectedDay = null
onDismissUpload()
}
)
}
}
@@ -290,13 +351,57 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
}
}
/** Report detail dialog (callsign / date / time / grid) */
/** Report detail dialog (callsign / date / time / grid) + AMSAT report upload panel. */
@Composable
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
private fun ReportDialog(
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
upload: AmSatUploadUiState,
onToggleUpload: () -> Unit,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit,
onConfirmSubmitReport: () -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
InfoDialog(
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
onAccept = onDismiss,
extraAction = {
CardButton(
onClick = onToggleUpload,
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
)
}
) {
AnimatedVisibility(visible = upload.isExpanded) {
AmSatUploadPanel(
statusName = statusName,
upload = upload,
onReportChange = onReportChange,
onCallsignChange = onCallsignChange,
onGridChange = onGridChange,
onRequestSubmitReport = onRequestSubmitReport
)
}
if (upload.isConfirmingSubmit) {
AmSatUploadConfirmDialog(
statusName = statusName,
upload = upload,
onConfirm = onConfirmSubmitReport,
onDismiss = onDismissSubmitConfirmation
)
}
if (dayReports.isEmpty()) {
Text(stringResource(id = R.string.amsat_no_reports))
Text(
text = stringResource(id = R.string.amsat_no_reports),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
@@ -326,6 +431,171 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
}
}
@Composable
private fun AmSatUploadPanel(
statusName: String,
upload: AmSatUploadUiState,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit
) {
val options = remember(statusName) {
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.amsat_upload_report),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.amsat_upload_status),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
options.forEach { option ->
val selected = upload.selectedReport == option.apiValue
FilterChip(
selected = selected,
onClick = { onReportChange(option.apiValue) },
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (selected) Color.White else option.color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(option.labelResId), maxLines = 1)
}
},
colors = FilterChipDefaults.filterChipColors(
containerColor = option.color.copy(alpha = 0.10f),
labelColor = MaterialTheme.colorScheme.onSurface,
selectedContainerColor = option.color,
selectedLabelColor = Color.White
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selected,
borderColor = option.color.copy(alpha = 0.60f),
selectedBorderColor = option.color
)
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = upload.callsign,
onValueChange = onCallsignChange,
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = upload.gridSquare,
onValueChange = onGridChange,
label = { Text(stringResource(R.string.amsat_upload_grid)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Button(
onClick = onRequestSubmitReport,
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (upload.isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(R.string.amsat_upload_submit))
}
}
upload.message?.let { message ->
if (message == UPLOAD_MESSAGE_SUCCESS) {
Text(
text = stringResource(R.string.amsat_upload_success),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
upload.error?.let { error ->
Text(
text = uploadErrorText(error),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun AmSatUploadConfirmDialog(
statusName: String,
upload: AmSatUploadUiState,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
val statusLabel = stringResource(option.labelResId)
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
text = {
Text(
text = stringResource(
R.string.amsat_upload_confirm_message,
statusName,
statusLabel,
upload.callsign,
grid
)
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.amsat_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
@Composable
private fun uploadErrorText(error: String): String {
return when (error) {
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
else -> stringResource(R.string.amsat_upload_failed, error)
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String {
@@ -343,3 +613,10 @@ private fun formatFetchedAt(utcMs: Long): String {
"$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,14 +4,38 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Locale
const val AMSAT_REPORT_HEARD = "Heard"
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
const val UPLOAD_MESSAGE_SUCCESS = "success"
data class AmSatUploadUiState(
val isExpanded: Boolean = false,
val selectedReport: String = AMSAT_REPORT_HEARD,
val callsign: String = "",
val gridSquare: String = "",
val isSubmitting: Boolean = false,
val isConfirmingSubmit: Boolean = false,
val message: String? = null,
val error: String? = null
)
data class SatStatusUiState(
val isLoading: Boolean = false,
@@ -19,11 +43,13 @@ data class SatStatusUiState(
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null
val error: String? = null,
val upload: AmSatUploadUiState = AmSatUploadUiState()
)
class SatStatusViewModel(
private val amSatRepo: IAmSatRepository
private val amSatRepo: IAmSatRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -39,15 +65,7 @@ class SatStatusViewModel(
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
applyStatusPage(page)
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
@@ -68,14 +86,7 @@ class SatStatusViewModel(
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
applyStatusPage(page)
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
@@ -85,11 +96,196 @@ class SatStatusViewModel(
}
}
private fun applyStatusPage(page: SatStatusPage) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs,
error = null
)
}
}
fun toggleUploadPanel() {
val storedCallsign = settingsRepo.getAmSatCallsign()
val defaultGrid = defaultGridSquare()
_uiState.update { state ->
val upload = state.upload
val expanded = !upload.isExpanded
state.copy(
upload = if (expanded) {
upload.copy(
isExpanded = true,
callsign = upload.callsign.ifBlank { storedCallsign },
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
message = null,
error = null
)
} else {
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
}
)
}
}
fun collapseUploadPanel() {
_uiState.update {
it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadReport(report: String) {
_uiState.update {
it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadCallsign(callsign: String) {
val normalized = callsign.trim().uppercase(Locale.US)
settingsRepo.setAmSatCallsign(normalized)
_uiState.update {
it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadGrid(grid: String) {
val normalized = grid.trim().uppercase(Locale.US)
_uiState.update {
it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null))
}
}
fun requestSubmitConfirmation() {
val prepared = validateUpload(_uiState.value.upload) ?: return
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isConfirmingSubmit = true,
message = null,
error = null
)
)
}
}
fun dismissSubmitConfirmation() {
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
}
fun confirmSubmitReport(satelliteName: String) {
val upload = _uiState.value.upload
if (upload.isSubmitting) return
val prepared = validateUpload(upload) ?: return
settingsRepo.setAmSatCallsign(prepared.callsign)
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isSubmitting = true,
isConfirmingSubmit = false,
error = null,
message = null
)
)
}
viewModelScope.launch {
val result = amSatRepo.submitReport(
AmSatReportSubmission(
name = satelliteName,
report = prepared.report,
callsign = prepared.callsign,
gridSquare = prepared.grid,
reportedAtUtcMillis = System.currentTimeMillis()
)
)
if (result.success) {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
callsign = prepared.callsign,
gridSquare = prepared.grid,
message = UPLOAD_MESSAGE_SUCCESS,
error = null
)
)
}
refresh()
} else {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
message = null,
error = result.message.ifBlank { "Upload failed" }
)
)
}
}
}
}
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
val callsign = upload.callsign.trim().uppercase(Locale.US)
val grid = upload.gridSquare.trim().uppercase(Locale.US)
return when {
callsign.isBlank() -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
message = null
)
)
}
null
}
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_GRID_INVALID,
message = null
)
)
}
null
}
else -> PreparedUpload(
report = upload.selectedReport,
callsign = callsign,
grid = grid
)
}
}
private fun defaultGridSquare(): String {
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container.amSatRepo)
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
}
}
}
}
private data class PreparedUpload(
val report: String,
val callsign: String,
val grid: String
)
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
+13 -9
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "446"
appVersionCode = "447"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6"
appVersionName = "4.4.7"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -12,24 +12,25 @@ jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.3.1"
android-gradle-plugin = "9.4.0"
androidx-core-ktx = "1.19.0"
androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4"
androidx-room = "2.8.5"
compose-bom = "2026.06.01"
compose-bom = "2026.09.00"
compose-activity = "1.13.0"
compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.5"
compose-navigation3 = "1.1.7"
google-ksp = "2.3.10"
google-ksp = "2.3.11"
kotlin = "2.4.10"
kotlin = "2.4.20"
kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0"
other-okhttp = "5.4.0"
other-detekt = "1.23.8"
other-okhttp = "5.5.0"
other-osmdroid = "6.1.20"
test-junit4 = "4.13.2"
@@ -72,6 +73,8 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve
#noinspection UnusedVersionCatalogEntry
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
#noinspection UnusedVersionCatalogEntry
other-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "other-detekt" }
#noinspection UnusedVersionCatalogEntry
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
#noinspection UnusedVersionCatalogEntry
@@ -90,6 +93,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
other-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "other-detekt" }
# plugins defined by this project
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
+2 -2
View File
@@ -1,7 +1,7 @@
#Tue Aug 11 14:16:53 CEST 2026
#Sat Sep 12 11:29:59 BST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME