Added basic convention plugins setup for modularization

This commit is contained in:
Arty Bishop committed 2026-03-01 20:02:30 +00:00
1 parent de99e09f5e
commit 4d88214c1d
5675 files changed
+10339 -9969

No files matched your search

@@ -24,8 +24,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.rtbishop.look4sat.presentation.MainScreen
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.MainTheme
class MainActivity : ComponentActivity() {
@@ -18,15 +18,19 @@
package com.rtbishop.look4sat
import android.app.Application
import com.rtbishop.look4sat.data.injection.MainContainer
import com.rtbishop.look4sat.core.data.injection.MainContainer
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.launch
class MainApplication : Application() {
class MainApplication : Application(), IContainerProvider {
lateinit var container: MainContainer
private lateinit var container: IMainContainer
override fun getMainContainer(): IMainContainer = container
override fun onCreate() {
super.onCreate()
@@ -15,7 +15,7 @@
* 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.presentation
package com.rtbishop.look4sat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -33,22 +33,14 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.presentation.common.hasEnoughHeight
import com.rtbishop.look4sat.presentation.common.hasEnoughWidth
import com.rtbishop.look4sat.presentation.map.mapDestination
import com.rtbishop.look4sat.presentation.passes.passesDestination
import com.rtbishop.look4sat.presentation.radar.radarDestination
import com.rtbishop.look4sat.presentation.satellites.satellitesDestination
import com.rtbishop.look4sat.presentation.settings.settingsDestination
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) {
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat)
data object Passes : Screen("passes", R.drawable.ic_passes, R.string.nav_pass)
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
}
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.map.mapDestination
import com.rtbishop.look4sat.feature.passes.passesDestination
import com.rtbishop.look4sat.feature.radar.radarDestination
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
import com.rtbishop.look4sat.feature.settings.settingsDestination
@Composable
fun MainScreen(navController: NavHostController = rememberNavController()) {
@@ -1,161 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
val LocalSpacing = compositionLocalOf { Spacing }
data object Spacing {
val extraExtraSmall = 4.dp
val extraSmall = 6.dp
val small = 8.dp
val medium = 12.dp
val large = 16.dp
val extraLarge = 24.dp
}
@Composable
fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val view = LocalView.current
if (view.isInEditMode) {
MaterialTheme(darkScheme, shapes, typography, content)
} else {
CompositionLocalProvider(LocalSpacing provides Spacing) {
val colorScheme = if (isDarkTheme) darkScheme else lightScheme
SideEffect {
val window = (view.context as ComponentActivity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
insetsController.isAppearanceLightNavigationBars = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
}
MaterialTheme(colorScheme, shapes, typography, content)
}
}
}
private val lightScheme = lightColorScheme(
primary = Color(0xFF715C0C),
onPrimary = Color(0xFFFFFFFF),
secondary = Color(0xFF685E40),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04),
background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0),
onSurface = Color(0xFF1E1B13),
surfaceTint = Color(0x00000000),
surfaceVariant = Color(0xFFEBE2CF),
onSurfaceVariant = Color(0xFF4C4639),
error = Color(0xFFDC0000),
outline = Color(0xFF7D7667),
scrim = Color(0xFF000000)
)
private val darkScheme = darkColorScheme(
primary = Color(0xFFFFE082), // main accent, switch background, active progress
onPrimary = Color(0xFF000000), // on main accent, switch knob
// primaryContainer = Color(0xFF121212),
// onPrimaryContainer = Color(0xFF121212),
// inversePrimary = Color(0xFF121212),
secondary = Color(0xFFE0E0E0),
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212),
// onTertiary = Color(0xFF121212),
// tertiaryContainer = Color(0xFF121212),
// onTertiaryContainer = Color(0xFF121212),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background
onSurface = Color(0xFFE0E0E0), // on card background
surfaceVariant = Color(0xFF404040), // buttons background
onSurfaceVariant = Color(0xFFE0E0E0), // navBar inactive icon
surfaceTint = Color(0x00000000),
// inverseSurface = Color(0xFF121212),
// inverseOnSurface = Color(0xFF121212),
error = Color(0xFFDC0000),
// onError = Color(0xFFFFFFFF),
// errorContainer = Color(0xFF121212),
// onErrorContainer = Color(0xFF121212),
outline = Color(0xA3E0E0E0),
// outlineVariant = Color(0xFF121212),
scrim = Color(0xFF000000),
// surfaceBright = Color(0xFF121212),
surfaceContainer = Color(0xFF121212), // navBar background
// surfaceContainerHigh = Color(0xFF121212),
surfaceContainerHighest = Color(0xFF242424), // filled card background
surfaceContainerLow = Color(0xFF202020), // elevated card background
surfaceContainerLowest = Color(0xCC121212),
// surfaceDim = Color(0xFF121212),
)
private val shapes = Shapes(
extraSmall = RoundedCornerShape(4.dp), // menus, snackbars, text fields
small = RoundedCornerShape(8.dp), // buttons, chips
medium = RoundedCornerShape(12.dp), // cards, small FABs
large = RoundedCornerShape(24.dp), // extended FABs, FABs, nav drawers
extraLarge = RoundedCornerShape(32.dp) // large FABs
)
private val typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
), titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
), labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
@@ -1,402 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.common
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.predict.NearEarthObject
import com.rtbishop.look4sat.domain.predict.OrbitalData
import com.rtbishop.look4sat.domain.predict.OrbitalPass
import com.rtbishop.look4sat.presentation.LocalSpacing
import com.rtbishop.look4sat.presentation.MainTheme
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
@Composable
@Preview(showBackground = true)
private fun TopBarPreview() = MainTheme {
TopBar {
IconCard(action = {}, resId = R.drawable.ic_filter)
TimerRow(timeString = "88:88:88", isTimeAos = true)
IconCard(action = {}, resId = R.drawable.ic_radios)
}
}
@Composable
fun TopBar(content: @Composable (RowScope.() -> Unit)) {
Row(
modifier = Modifier.height(48.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { content() }
}
@Composable
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
val colorScheme = MaterialTheme.colorScheme
val aosColor = if (isTimeAos) colorScheme.primary else colorScheme.onSurface
val losColor = if (!isTimeAos) colorScheme.primary else colorScheme.onSurface
ElevatedCard(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxSize()
) {
Text(text = "AOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = aosColor)
Text(
text = timeString,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
}
}
}
@Composable
@Preview(showBackground = true)
private fun NextPassRowPreview() = MainTheme {
TopBar { NextPassRow(pass = getDefaultPass()) }
}
@Composable
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.height(48.dp).weight(1f)) {
Column(
verticalArrangement = Arrangement.spacedBy((-2).dp),
modifier = Modifier
.fillMaxSize()
// .background(color = MaterialTheme.colorScheme.background)
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = sdfTime.format(Date(pass.aosTime)),
fontSize = 15.sp,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.altitude} km",
fontSize = 15.sp
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp
)
}
}
}
}
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
onClick = { onClick() }, colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
), shape = MaterialTheme.shapes.small, modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
}
@Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
val clickableMod = Modifier.clickable { action() }
ElevatedCard(modifier = Modifier.size(48.dp)) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
}
}
}
@Composable
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
val clickableMod = modifier.clickable { onClick() }
ElevatedCard(
modifier = Modifier.size(102.dp, 48.dp),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(id = resId), contentDescription = null)
}
}
}
@Composable
fun CardLoadingIndicator() {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.size(80.dp))
}
}
@Composable
fun EmptyListCard(message: String) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(text = stringResource(R.string.empty_list_message), fontSize = 21.sp, textAlign = TextAlign.Center)
Text(text = message, fontSize = 18.sp, textAlign = TextAlign.Center)
}
}
}
fun getDefaultPass(): OrbitalPass {
val orbitalData = OrbitalData(""" ¯\_(ツ)_/¯ ⚠️""", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 0.0)
val satellite = NearEarthObject(orbitalData)
return OrbitalPass(0L, 0.0, Long.MAX_VALUE, 0.0, 0, 0.0, satellite, 0f)
}
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) {
val padding = LocalSpacing.current.large
Dialog(onDismissRequest = { onCancel() }) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(padding)
) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding)
)
content()
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
CardButton(onClick = onCancel, text = stringResource(id = R.string.btn_cancel))
Spacer(modifier = Modifier.weight(1f))
CardButton(onClick = onAccept, text = stringResource(id = R.string.btn_accept))
}
}
}
}
}
@Composable
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
val padding = LocalSpacing.current.large
Dialog(onDismissRequest = {}) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(padding)
) {
Text(
text = title,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding)
)
Text(
text = text,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
Spacer(modifier = Modifier.weight(1f))
CardButton(
onClick = onDismiss,
text = stringResource(id = R.string.btn_accept)
)
}
}
}
}
}
@Composable
fun hasEnoughHeight(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
}
@Composable
fun hasEnoughWidth(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
}
@Composable
fun isVerticalLayout(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600).not()
}
@Composable
fun Modifier.infiniteMarquee(): Modifier {
return basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
}
@Composable
fun Modifier.layoutPadding(): Modifier {
val statusBarMod = this.statusBarsPadding()
val spacing = LocalSpacing.current.extraSmall
return statusBarMod.padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
}
@Composable
fun ScreenColumn(
topBar: @Composable (Boolean) -> Unit = {},
floatingBar: @Composable () -> Unit = {},
content: @Composable (Boolean) -> Unit = {}
) {
val isVerticalLayout = isVerticalLayout()
Surface(color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
topBar(isVerticalLayout)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceContainer
) { content(isVerticalLayout) }
}
floatingBar()
}
}
}
@Composable
fun TopBar(
isVerticalLayout: Boolean,
startAction: @Composable () -> Unit,
topInfo: @Composable (RowScope.() -> Unit),
bottomInfo: @Composable (RowScope.() -> Unit),
endAction: @Composable () -> Unit
) {
val topBarRow = @Composable { content: @Composable RowScope.() -> Unit ->
Row(
modifier = Modifier.height(48.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { content() }
}
if (isVerticalLayout) {
topBarRow { startAction().also { topInfo() }.also { endAction() } }
topBarRow { bottomInfo() }
} else {
topBarRow { startAction().also { topInfo() }.also { bottomInfo() }.also { endAction() } }
}
}
@@ -1,367 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.map
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.predict.GeoPos
import com.rtbishop.look4sat.domain.predict.OrbitalObject
import com.rtbishop.look4sat.domain.predict.OrbitalPos
import com.rtbishop.look4sat.presentation.Screen
import com.rtbishop.look4sat.presentation.common.IconCard
import com.rtbishop.look4sat.presentation.common.NextPassRow
import com.rtbishop.look4sat.presentation.common.TimerRow
import com.rtbishop.look4sat.presentation.common.TopBar
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.FolderOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import androidx.core.graphics.toColorInt
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
import com.rtbishop.look4sat.presentation.common.layoutPadding
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 3f
style = Paint.Style.STROKE
color = Color.RED
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 3f
style = Paint.Style.FILL_AND_STROKE
color = "#FFE082".toColorInt()
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 36f
style = Paint.Style.FILL
color = "#FFE082".toColorInt()
setShadowLayer(3f, 3f, 3f, Color.BLACK)
}
private val labelRect = Rect()
fun NavGraphBuilder.mapDestination() {
composable(Screen.Map.route) {
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, mapView)
}
}
@Composable
private fun MapScreen(uiState: State<MapState>, mapView: MapView) {
val onItemClick = { item: OrbitalObject -> uiState.value.sendAction(MapAction.SelectItem(item)) }
val selectPrev = { uiState.value.sendAction(MapAction.SelectPrev) }
val selectNext = { uiState.value.sendAction(MapAction.SelectNext) }
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.value.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.value.mapData?.isTimeAos ?: true
LaunchedEffect(uiState.value.track) {
val latitude = uiState.value.track?.get(0)?.get(0)?.latitude ?: 0.0
val longitude = uiState.value.track?.get(0)?.get(0)?.longitude ?: 0.0
mapView.controller.animateTo(GeoPoint(latitude, longitude))
}
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
}
TopBar { NextPassRow(pass = uiState.value.orbitalPass) }
} else {
TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
NextPassRow(pass = uiState.value.orbitalPass, modifier = Modifier.weight(1f))
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { mapView ->
uiState.value.stationPosition?.let { setStationPosition(it, mapView) }
uiState.value.positions?.let { setPositions(it, mapView, onItemClick) }
uiState.value.track?.let { setSatelliteTrack(it, mapView) }
uiState.value.footprint?.let { setFootprint(it, mapView) }
}
uiState.value.mapData?.let { mapData ->
if (isVerticalLayout()) MapDataCard(mapData) else MapDataCards(mapData)
}
}
}
}
}
@Composable
private fun MapDataCard(data: MapData) {
val textColor = MaterialTheme.colorScheme.primary
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween) {
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor)
Text(text = stringResource(R.string.map_elevation, data.elevation), color = textColor)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_altitude, data.altitude))
Text(text = stringResource(R.string.map_distance, data.range))
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude), color = textColor)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_qth, data.qthLoc))
Text(text = stringResource(R.string.map_phase, data.phase))
}
}
}
}
}
@Composable
private fun MapDataCards(data: MapData) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).width(160.dp)
val osmText = stringResource(R.string.map_copyright)
val textColor = MaterialTheme.colorScheme.primary
Box(modifier = Modifier.fillMaxSize()) {
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) {
Column(horizontalAlignment = Alignment.Start, modifier = paddingMod) {
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor)
Text(text = stringResource(R.string.map_elevation, data.elevation))
}
}
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopEnd)) {
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
Text(text = stringResource(R.string.map_altitude, data.altitude), color = textColor)
Text(text = stringResource(R.string.map_distance, data.range))
}
}
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomStart)) {
Column(horizontalAlignment = Alignment.Start, modifier = paddingMod) {
Text(text = stringResource(R.string.map_phase, data.phase), color = textColor)
Text(text = stringResource(R.string.map_qth, data.qthLoc))
}
}
Text(text = osmText, fontSize = 14.sp, modifier = Modifier.align(Alignment.BottomCenter))
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) {
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude))
}
}
}
}
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try {
Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
position = GeoPoint(stationPos.latitude, stationPos.longitude)
mapView.overlays[0] = this
mapView.invalidate()
}
} catch (exception: Exception) {
println(exception)
}
}
private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView,
action: (OrbitalObject) -> Unit
) {
val markers = FolderOverlay()
try {
posMap.entries.forEach {
Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCustomTextIcon(it.key.data.name, mapView)
try {
position = GeoPoint(it.value.latitude, it.value.longitude)
} catch (exception: IllegalArgumentException) {
println(exception.stackTraceToString())
}
setOnMarkerClickListener { _, _ ->
action(it.key)
return@setOnMarkerClickListener true
}
markers.add(this)
}
}
mapView.overlays[3] = markers
mapView.invalidate()
} catch (exception: Exception) {
println(exception)
}
}
private fun getCustomTextIcon(textLabel: String, mapView: MapView): Drawable {
textPaint.getTextBounds(textLabel, 0, textLabel.length, labelRect)
val iconSize = 10f
val width = labelRect.width() + iconSize * 2f
val height = textPaint.textSize * 3f + iconSize * 2f
val bitmap = createBitmap(width.toInt(), height.toInt())
Canvas(bitmap).run {
drawCircle(width / 2f, height / 2f, iconSize, textPaint)
drawText(textLabel, iconSize / 2f, height - iconSize, textPaint)
}
return bitmap.toDrawable(mapView.context.resources)
}
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
val trackOverlay = FolderOverlay()
try {
satTrack.forEach { track ->
val trackPoints = track.map { GeoPoint(it.latitude, it.longitude) }
Polyline().apply {
setPoints(trackPoints)
outlinePaint.set(trackPaint)
trackOverlay.add(this)
}
}
mapView.overlays[1] = trackOverlay
} catch (exception: Exception) {
println(exception)
}
}
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
val footprintPoints = orbitalPos.getRangeCircle().map { GeoPoint(it.latitude, it.longitude) }
try {
val footprintOverlay = Polyline().apply {
outlinePaint.set(footprintPaint)
setPoints(footprintPoints)
}
mapView.overlays[2] = footprintOverlay
} catch (exception: Exception) {
println(exception)
}
}
@Composable
private fun rememberMapViewWithLifecycle(): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current
val mapView = remember { MapView(context) }.apply {
setMultiTouchControls(true)
setUseDataConnection(false)
setTileSource(tileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels)
maxZoomLevel = 7.0
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
overlayManager.tilesOverlay.setColorFilter(getColorFilter())
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
// add overlays: 0 - GSP, 1 - SatTrack, 2 - SatFootprint, 3 - SatIcons
overlays.addAll(Array(4) { FolderOverlay() })
}
// Makes MapView follow the lifecycle of this composable
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
lifecycle.addObserver(lifecycleObserver)
onDispose { lifecycle.removeObserver(lifecycleObserver) }
}
return mapView
}
@Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
else -> {}
}
}
}
@Composable
private fun getColorFilter(): ColorMatrixColorFilter {
val grayScale = ColorMatrix().apply { setSaturation(0f) }
val negative = ColorMatrix(
floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f)
)
negative.preConcat(grayScale)
return ColorMatrixColorFilter(negative)
}
@Composable
private fun getMinZoom(screenHeight: Int): Double {
if (!isVerticalLayout()) return 3.5
return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight)
}
@@ -1,58 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.map
import com.rtbishop.look4sat.domain.predict.GeoPos
import com.rtbishop.look4sat.domain.predict.OrbitalObject
import com.rtbishop.look4sat.domain.predict.OrbitalPass
import com.rtbishop.look4sat.domain.predict.OrbitalPos
data class MapState(
val mapData: MapData?,
val isLightUi: Boolean,
val stationPosition: GeoPos?,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>?,
val footprint: OrbitalPos?,
val positions: Map<OrbitalObject, GeoPos>?,
val sendAction: (MapAction) -> Unit
)
sealed class MapAction {
data object SelectPrev: MapAction()
data object SelectNext: MapAction()
data class SelectItem(val item: OrbitalObject): MapAction()
data class SelectDefaultItem(val catnum: Int): MapAction()
}
data class MapData(
val catNum: Int,
val name: String,
val aosTime: String,
val isTimeAos: Boolean,
val azimuth: Double,
val elevation: Double,
val range: Double,
val altitude: Double,
val velocity: Double,
val qthLoc: String,
val osmPos: GeoPos,
val period: Double,
val phase: Double,
val eclipsed: Boolean
)
@@ -1,235 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.domain.predict.GeoPos
import com.rtbishop.look4sat.domain.predict.OrbitalObject
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.domain.utility.clipLat
import com.rtbishop.look4sat.domain.utility.clipLon
import com.rtbishop.look4sat.domain.utility.positionToQth
import com.rtbishop.look4sat.domain.utility.toDegrees
import com.rtbishop.look4sat.domain.utility.toTimerString
import com.rtbishop.look4sat.presentation.common.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.collections.set
class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISettingsRepo) :
ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow(
MapState(
mapData = null,
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
stationPosition = null,
orbitalPass = defaultPass,
track = null,
footprint = null,
positions = null,
sendAction = ::handleAction
)
)
private var allPasses = satelliteRepo.passes.value
private var allSatellites = satelliteRepo.satellites.value
private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L
private var selectedOrbitalObject: OrbitalObject? = null
val uiState: StateFlow<MapState> = _uiState
init {
selectDefaultSatellite(-1)
}
private fun handleAction(action: MapAction) {
when (action) {
MapAction.SelectPrev -> scrollSelection(true)
MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
}
}
private fun getStationPosition() {
val osmLat = clipLat(stationPos.latitude)
val osmLon = clipLon(stationPos.longitude)
_uiState.update { it.copy(stationPosition = GeoPos(osmLat, osmLon)) }
}
private fun selectDefaultSatellite(catnum: Int) {
if (allSatellites.isNotEmpty()) {
if (catnum == -1) {
allPasses.find { pass -> pass.progress < 100 && !pass.isDeepSpace }
?.let { pass -> selectSatellite(pass.orbitalObject) }
} else {
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
}
}
}
private fun scrollSelection(decrement: Boolean) {
if (allSatellites.isNotEmpty()) {
val index = allSatellites.indexOf(selectedOrbitalObject)
if (decrement) {
if (index > 0) selectSatellite(allSatellites[index - 1])
else selectSatellite(allSatellites[allSatellites.size - 1])
} else {
if (index < allSatellites.size - 1) selectSatellite(allSatellites[index + 1])
else selectSatellite(allSatellites[0])
}
}
}
private fun selectSatellite(orbitalObject: OrbitalObject, updateFreq: Long = dataUpdateRate) {
selectedOrbitalObject = orbitalObject
viewModelScope.launch {
dataUpdateJob?.cancelAndJoin()
dataUpdateJob = launch {
val dateNow = Date()
getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow)
while (isActive) {
dateNow.time = System.currentTimeMillis()
getPositions(allSatellites, stationPos, dateNow)
getSatFootprint(orbitalObject, stationPos, dateNow)
getSatData(orbitalObject, stationPos, dateNow)
delay(updateFreq)
}
}
}
}
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
val satTracks = mutableListOf<List<GeoPos>>()
val currentTrack = mutableListOf<GeoPos>()
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
var oldLongitude = 0.0
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
val currentPosition = GeoPos(osmLat, osmLon)
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
// adding left terminal position
currentTrack.add(GeoPos(osmLat, -180.0))
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
// adding right terminal position
currentTrack.add(GeoPos(osmLat, 180.0))
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
}
oldLongitude = currentPosition.longitude
currentTrack.add(currentPosition)
}
satTracks.add(currentTrack)
_uiState.update { it.copy(track = satTracks) }
}
private suspend fun getPositions(orbitalObjects: List<OrbitalObject>, pos: GeoPos, date: Date) {
val positions = mutableMapOf<OrbitalObject, GeoPos>()
orbitalObjects.forEach { satellite ->
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
positions[satellite] = GeoPos(osmLat, osmLon)
}
_uiState.update { it.copy(positions = positions) }
}
private suspend fun getSatFootprint(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
val satPos = satelliteRepo.getPosition(orbitalObject, pos, date.time)
_uiState.update { it.copy(footprint = satPos) }
}
private suspend fun getSatData(sat: OrbitalObject, pos: GeoPos, date: Date) {
var orbitalPass = defaultPass
var aosTime = 0L.toTimerString()
var isTimeAos = true
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
aosTime = if (date.time < satPass.aosTime) {
val millisBeforeStart = satPass.aosTime.minus(date.time)
isTimeAos = true
millisBeforeStart.toTimerString()
} else {
val millisBeforeEnd = satPass.losTime.minus(date.time)
isTimeAos = false
millisBeforeEnd.toTimerString()
}
}
}
val satPos = satelliteRepo.getPosition(sat, pos, date.time)
val azimuth = satPos.azimuth.toDegrees()
val elevation = satPos.elevation.toDegrees()
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
val osmPos = GeoPos(osmLat, osmLon)
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
val velocity = satPos.getOrbitalVelocity()
val phase = satPos.phase.toDegrees()
val visibility = satPos.eclipsed
val satData = MapData(
sat.data.catnum,
sat.data.name,
aosTime,
isTimeAos,
azimuth,
elevation,
satPos.distance,
satPos.altitude,
velocity,
qthLoc,
osmPos,
sat.data.orbitalPeriod,
phase,
visibility
)
_uiState.update { it.copy(mapData = satData, orbitalPass = orbitalPass) }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer {
val container = (this[applicationKey] as MainApplication).container
MapViewModel(container.satelliteRepo, container.settingsRepo)
}
}
}
}
@@ -1,189 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.passes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.presentation.LocalSpacing
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.common.SharedDialog
private val allModes = listOf(
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
)
@Preview
@Composable
private fun PassesDialogPreview() {
MainTheme { PassesDialog(24, 16.0, {}) { _, _ -> } }
}
@Composable
fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
val hoursValue = remember { mutableIntStateOf(hours) }
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val onAccept = {
accept(hoursValue.intValue, elevationValueNew.doubleValue).also { cancel() }
}
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
SliderRow(
title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue,
valuePostfix = "°",
valueResId = R.drawable.ic_elevation,
valueRange = 0f..60f
) { elevationValueNew.doubleValue = it.toDouble() }
SliderRow(
title = stringResource(R.string.pass_filter_hours),
value = hoursValue.intValue.toDouble(),
valuePostfix = "h",
valueResId = R.drawable.ic_clock,
valueRange = 1f..240f
) { hoursValue.intValue = it.toInt() }
}
}
@Composable
private fun SliderRow(
title: String,
value: Double,
valuePostfix: String,
valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>,
onChange: (Float) -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.small),
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(LocalSpacing.current.small),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
fontSize = 16.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurface
)
Icon(
painter = painterResource(id = valueResId),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = "${value.toInt()}$valuePostfix",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange)
}
}
@Preview(showBackground = true)
@Composable
private fun RadiosDialogPreview() {
MainTheme { RadiosDialog(emptyList(), {}) { _ -> } }
}
@Composable
fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
val selected = remember { mutableStateListOf<String>().apply { addAll(modes) } }
val select = { mode: String ->
if (selected.contains(mode)) selected.remove(mode) else selected.add(mode)
}
val onAccept = { accept(selected.toList()).also { cancel() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
modifier = Modifier
.fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background),
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allModes) { index, item ->
Surface {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { select(item) }
) {
Text(
text = "${index + 1}).",
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary
)
Text(
text = item,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = selected.contains(item),
onCheckedChange = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
}
}
}
@@ -1,336 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.passes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.predict.DeepSpaceObject
import com.rtbishop.look4sat.domain.predict.NearEarthObject
import com.rtbishop.look4sat.domain.predict.OrbitalData
import com.rtbishop.look4sat.domain.predict.OrbitalPass
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.Screen
import com.rtbishop.look4sat.presentation.common.EmptyListCard
import com.rtbishop.look4sat.presentation.common.IconCard
import com.rtbishop.look4sat.presentation.common.InfoDialog
import com.rtbishop.look4sat.presentation.common.NextPassRow
import com.rtbishop.look4sat.presentation.common.ScreenColumn
import com.rtbishop.look4sat.presentation.common.TimerRow
import com.rtbishop.look4sat.presentation.common.TopBar
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sdfDate = SimpleDateFormat("EEE dd MMM", Locale.ENGLISH)
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
composable(Screen.Passes.route) {
val viewModel = viewModel(
modelClass = PassesViewModel::class.java,
factory = PassesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, navigateToRadar)
}
}
@Composable
private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> Unit) {
val refreshList = { uiState.takeAction(PassesAction.RefreshPasses) }
val showPassesDialog = { uiState.takeAction(PassesAction.TogglePassesDialog) }
val showRadiosDialog = { uiState.takeAction(PassesAction.ToggleRadiosDialog) }
if (uiState.isPassesDialogShown) {
PassesDialog(
hours = uiState.hours,
elevation = uiState.elevation,
cancel = showPassesDialog
) { hours, elevation ->
uiState.takeAction(PassesAction.FilterPasses(hours, elevation))
}
}
if (uiState.isRadiosDialogShown) {
RadiosDialog(modes = uiState.modes, cancel = showRadiosDialog) { modes ->
uiState.takeAction(PassesAction.FilterRadios(modes))
}
}
if (uiState.shouldSeeWhatsNew) {
InfoDialog(
title = stringResource(R.string.pass_whatsnew_title),
text = stringResource(R.string.pass_whatsnew_message)
) {
uiState.takeAction(PassesAction.DismissWhatsNew)
}
}
val gridState = rememberLazyGridState()
ScreenColumn(
topBar = { isVerticalLayout ->
TopBar(
isVerticalLayout = isVerticalLayout,
startAction = {
IconCard(action = showPassesDialog, resId = R.drawable.ic_filter)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
},
bottomInfo = {
NextPassRow(pass = uiState.nextPass)
},
endAction = {
IconCard(action = showRadiosDialog, resId = R.drawable.ic_radios)
}
)
}
) { _ ->
PassesList(
isRefreshing = uiState.isRefreshing,
passes = uiState.itemsList,
navigateToRadar = navigateToRadar,
refreshPasses = refreshList,
gridState = gridState
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PassesList(
isRefreshing: Boolean,
passes: List<OrbitalPass>,
navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit,
gridState: LazyGridState
) {
val isVerticalLayout = isVerticalLayout()
val refreshState = rememberPullToRefreshState()
ElevatedCard(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = isRefreshing,
state = refreshState,
onRefresh = refreshPasses,
indicator = {
PullToRefreshDefaults.Indicator(
state = refreshState,
isRefreshing = isRefreshing,
color = MaterialTheme.colorScheme.background,
containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
} else {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Adaptive(320.dp),
modifier = Modifier.fillMaxSize()
) {
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
NearEarthPass(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout
)
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun DeepSpacePassPreview() {
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = DeepSpaceObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
}
@Preview(showBackground = true)
@Composable
private fun NearEarthPassPreview() {
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = NearEarthObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
}
@Composable
private fun NearEarthPass(
pass: OrbitalPass,
navigateToRadar: (Int, Long) -> Unit,
modifier: Modifier = Modifier,
isVerticalLayout: Boolean = true
) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
Surface(modifier = Modifier
.padding(bottom = 2.dp)
.clickable { navigateToRadar(pass.catNum, pass.aosTime) }) {
Column(
verticalArrangement = Arrangement.spacedBy(1.dp),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (pass.isDeepSpace) {
Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp)
} else {
Text(
text = sdfDate.format(Date(pass.aosTime)),
fontSize = 15.sp
)
}
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.altitude} km",
fontSize = 15.sp
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
val defaultTime = " - - : - - "
Text(
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.aosTime)),
fontSize = 15.sp
)
LinearProgressIndicator(
progress = { if (pass.isDeepSpace) 100f else pass.progress },
drawStopIndicator = {},
modifier = modifier.fillMaxWidth(0.75f)
)
Text(
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.losTime)),
fontSize = 15.sp
)
}
}
}
}
}
@@ -1,45 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.passes
import com.rtbishop.look4sat.domain.predict.OrbitalPass
data class PassesState(
val isPassesDialogShown: Boolean,
val isRadiosDialogShown: Boolean,
val isRefreshing: Boolean,
val isUtc: Boolean,
val nextPass: OrbitalPass,
val nextTime: String,
val isNextTimeAos: Boolean,
val hours: Int,
val elevation: Double,
val modes: List<String>,
val itemsList: List<OrbitalPass>,
val shouldSeeWhatsNew: Boolean,
val takeAction: (PassesAction) -> Unit
)
sealed class PassesAction {
data object DismissWhatsNew : PassesAction()
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction()
data class FilterRadios(val modes: List<String>) : PassesAction()
data object RefreshPasses : PassesAction()
data object TogglePassesDialog : PassesAction()
data object ToggleRadiosDialog : PassesAction()
}
@@ -1,148 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.passes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.domain.model.PassesSettings
import com.rtbishop.look4sat.domain.predict.OrbitalPass
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.domain.utility.toTimerString
import com.rtbishop.look4sat.presentation.common.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(
PassesState(
isPassesDialogShown = false,
isRadiosDialogShown = false,
isRefreshing = true,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextTime = "00:00:00",
isNextTimeAos = true,
nextPass = getDefaultPass(),
hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation,
modes = settingsRepo.passesSettings.value.selectedModes,
itemsList = emptyList(),
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew,
takeAction = ::handleAction
)
)
private var processing: Job? = null
val uiState: StateFlow<PassesState> = _uiState
init {
viewModelScope.launch {
delay(1000)
satelliteRepo.passes.collectLatest { passes ->
processing?.cancelAndJoin()
processing = viewModelScope.launch {
while (isActive) {
val timeNow = System.currentTimeMillis()
val newPasses = satelliteRepo.processPasses(passes, timeNow)
setPassInfo(newPasses, timeNow)
_uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) }
delay(1000)
}
}
}
}
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
}
}
}
private fun handleAction(action: PassesAction) {
when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, uiState.value.modes)
is PassesAction.FilterRadios -> applyFilter(uiState.value.hours, uiState.value.elevation, action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> toggleFilterDialog()
PassesAction.ToggleRadiosDialog -> toggleRadiosDialog()
}
}
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
processing?.cancelAndJoin()
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) }
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
}
private fun refreshPasses() = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
processing?.cancelAndJoin()
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
}
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
if (passes.isEmpty()) return
try {
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
val time = nextPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
} catch (_: NoSuchElementException) {
val lastPass = passes.last()
val time = lastPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
}
}
private fun toggleFilterDialog() {
val currentDialogState = _uiState.value.isPassesDialogShown
_uiState.update { it.copy(isPassesDialogShown = currentDialogState.not()) }
}
private fun toggleRadiosDialog() {
val currentDialogState = _uiState.value.isRadiosDialogShown
_uiState.update { it.copy(isRadiosDialogShown = currentDialogState.not()) }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer {
val container = (this[applicationKey] as MainApplication).container
PassesViewModel(container.satelliteRepo, container.settingsRepo)
}
}
}
}
@@ -1,489 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.radar
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.model.SatRadio
import com.rtbishop.look4sat.domain.utility.toDegrees
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.Screen
import com.rtbishop.look4sat.presentation.common.EmptyListCard
import com.rtbishop.look4sat.presentation.common.IconCard
import com.rtbishop.look4sat.presentation.common.NextPassRow
import com.rtbishop.look4sat.presentation.common.TimerRow
import com.rtbishop.look4sat.presentation.common.TopBar
import com.rtbishop.look4sat.presentation.common.getDefaultPass
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
import com.rtbishop.look4sat.presentation.common.layoutPadding
fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
val radarArgs = listOf(
navArgument("catNum") { defaultValue = 0 },
navArgument("aosTime") { defaultValue = 0L }
)
composable(radarRoute, radarArgs) {
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
RadarScreen(uiState, navigateUp)
}
}
@Composable
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
// BluetoothCIV.init(LocalContext.current)
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass ->
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
}
}
val upcomingPass = uiState.currentPass ?: getDefaultPass()
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
Column(modifier = Modifier.layoutPadding().keepScreenOn(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
TopBar { NextPassRow(pass = upcomingPass) }
} else {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f))
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
}
if(isVerticalLayout()) {
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.Center) {
if (uiState.orbitalPos == null) {
ElevatedCard(modifier = Modifier.fillMaxSize()) {
EmptyListCard(message = "")
}
}
uiState.orbitalPos?.let { position ->
RadarViewCompose(
item = position,
items = uiState.satTrack,
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
modifier = Modifier.align(Alignment.Center)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextTop(
position.azimuth,
stringResource(R.string.radar_az_text),
true
)
RadarTextTop(
position.elevation,
stringResource(R.string.radar_el_text),
false
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextBottom(
position.altitude,
stringResource(R.string.radar_alt_text),
true
)
RadarTextBottom(
position.distance,
stringResource(R.string.radar_dist_text),
false
)
}
}
}
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
if (uiState.transmitters.isEmpty()) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(
text = stringResource(R.string.empty_list_message),
fontSize = 21.sp,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.radar_no_data),
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
TransmittersList(
transmitters = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
onSelect = { uuid ->
if (uiState.selectedTransmitterUuid != null) {
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
}
}
)
if (uiState.orbitalPos?.eclipsed == true) {
EclipsedIndicator()
}
}
}
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.Center) {
if (uiState.orbitalPos == null) {
ElevatedCard(modifier = Modifier.fillMaxSize()) {
EmptyListCard(message = "")
}
}
uiState.orbitalPos?.let { position ->
RadarViewCompose(
item = position,
items = uiState.satTrack,
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = false,
modifier = Modifier.align(Alignment.Center)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextTop(
position.azimuth,
stringResource(R.string.radar_az_text),
true
)
RadarTextTop(
position.elevation,
stringResource(R.string.radar_el_text),
false
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextBottom(
position.altitude,
stringResource(R.string.radar_alt_text),
true
)
RadarTextBottom(
position.distance,
stringResource(R.string.radar_dist_text),
false
)
}
}
}
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
if (uiState.transmitters.isEmpty()) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(
text = stringResource(R.string.empty_list_message),
fontSize = 21.sp,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.radar_no_data),
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
TransmittersList(
transmitters = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
onSelect = { uuid ->
if (uiState.selectedTransmitterUuid != null) {
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
}
}
)
if (uiState.orbitalPos?.eclipsed == true) {
EclipsedIndicator()
}
}
}
}
}
}
}
}
@Composable
private fun RadarTextTop(value: Double, text: String, isLeft: Boolean) {
val alignment = if (isLeft) Alignment.Start else Alignment.End
val degValue = stringResource(R.string.radar_az_value, value.toDegrees())
Column(horizontalAlignment = alignment) {
Text(text = degValue, fontSize = 18.sp)
Text(text = text, fontSize = 15.sp)
}
}
@Composable
private fun RadarTextBottom(value: Double, text: String, isLeft: Boolean) {
val alignment = if (isLeft) Alignment.Start else Alignment.End
val degValue = stringResource(R.string.radar_alt_value, value)
Column(horizontalAlignment = alignment) {
Text(text = text, fontSize = 15.sp)
Text(text = degValue, fontSize = 18.sp)
}
}
@Composable
private fun TransmittersList(
transmitters: List<SatRadio>,
selectedUuid: String?,
onSelect: (String) -> Unit
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = transmitters, key = { item -> item.uuid }) { radio ->
TransmitterItem(
radio,
(selectedUuid != null),
(radio.uuid == selectedUuid)
) { onSelect(radio.uuid) }
}
}
}
@Preview
@Composable
private fun TransmitterItemPreview() {
val transmitter = SatRadio(
"", "Extremely powerful transmitter", true, 10000000000L, 10000000000L,
null, 10000000000L, 10000000000L, "FSK AX.100 Mode 5", true, 0
)
MainTheme { TransmitterItem(transmitter, isClickable = true, isSelected = true, onClick = {}) }
}
@Composable
private fun EclipsedIndicator() {
val bgColor = MaterialTheme.colorScheme.primary
val bgShape = MaterialTheme.shapes.medium
val infiniteTransition = rememberInfiniteTransition()
val textAlpha = infiniteTransition.animateFloat(
initialValue = 1.0f,
targetValue = 0.0f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
delayMillis = 25,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier
.fillMaxSize()
.alpha(textAlpha.value)
.border(width = 2.dp, color = bgColor, shape = bgShape),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.radar_eclipsed),
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.background,
modifier = Modifier
.background(color = bgColor, shape = bgShape)
.padding(12.dp)
)
}
}
@Composable
private fun TransmitterItem(
radio: SatRadio,
isClickable: Boolean,
isSelected: Boolean,
onClick: () -> Unit
) {
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier
.fillMaxWidth()
.then(if (isClickable) Modifier.clickable { onClick() } else Modifier)
) {
Surface(modifier = Modifier.padding(bottom = 2.dp)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Box {
Text(
text = fullTitle,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp)
.infiniteMarquee()
)
if (isSelected) {
Icon(
painter = painterResource(id = R.drawable.ic_radios),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)
)
}
}
FrequencyRow(radio = radio, isDownlink = true)
FrequencyRow(radio = radio, isDownlink = false)
}
}
}
}
@Composable
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean, modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) {
val rotateMod = Modifier.rotate(if (isDownlink) 90f else -90f)
val weightMod = Modifier.weight(1f)
val desc = if (isDownlink) stringResource(R.string.radar_downlink) else stringResource(R.string.radar_uplink)
Text(
text = if (isDownlink) "D:" else "U:",
textAlign = TextAlign.Center,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier.size(width = 24.dp, height = 24.dp).semantics { contentDescription = desc }
)
FrequencyText(if (isDownlink) radio.downlinkLow else radio.uplinkLow, weightMod)
Text(
text = "-",
textAlign = TextAlign.Center,
fontSize = 21.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = modifier
)
FrequencyText(if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, weightMod)
Icon(
painter = painterResource(id = R.drawable.ic_arrow),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null,
modifier = rotateMod.size(width = 24.dp, height = 24.dp)
)
}
}
@Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val noLinkText = stringResource(R.string.radar_no_link)
val freqValue = frequency?.let { it / 1000000f }
Text(
text = freqValue?.let { stringResource(id = R.string.radar_link_low, it) } ?: noLinkText,
textAlign = TextAlign.Center,
fontSize = 21.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = modifier
)
}
@@ -1,42 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.radar
import com.rtbishop.look4sat.domain.model.SatRadio
import com.rtbishop.look4sat.domain.predict.OrbitalPass
import com.rtbishop.look4sat.domain.predict.OrbitalPos
data class RadarState(
val currentPass: OrbitalPass?,
val currentTime: String,
val isCurrentTimeAos: Boolean,
val orientationValues: Pair<Float, Float>,
val orbitalPos: OrbitalPos?,
val satTrack: List<OrbitalPos>,
val shouldShowSweep: Boolean,
val shouldUseCompass: Boolean,
val transmitters: List<SatRadio>,
val selectedTransmitterUuid: String?,
val selectedFrequency: Long?,
val sendAction: (RadarAction) -> Unit
)
sealed class RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction()
data class SelectTransmitter(val uuid: String) : RadarAction()
}
@@ -1,252 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.radar
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.StampedPathEffectStyle
import androidx.compose.ui.graphics.SweepGradientShader
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.domain.predict.OrbitalPos
import com.rtbishop.look4sat.domain.predict.PI_2
import com.rtbishop.look4sat.domain.utility.toRadians
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun RadarViewCompose(
item: OrbitalPos,
items: List<OrbitalPos>,
azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean,
shouldUseCompass: Boolean,
modifier: Modifier = Modifier
) {
// val context = LocalContext.current
// val view = LocalView.current
val radarColor = MaterialTheme.colorScheme.secondary
val trackColor = MaterialTheme.colorScheme.primary
val aimColor = MaterialTheme.colorScheme.error
val strokeWidth = 6f
val animTransition = rememberInfiniteTransition(label = "animScale")
val animSpec = infiniteRepeatable<Float>(tween(1000))
val animScale = animTransition.animateFloat(16f, 64f, animSpec, label = "animScale")
// val aimThreshold = 0.05f
val measurer = rememberTextMeasurer()
val sweepDegrees = remember { mutableFloatStateOf(0f) }
val trackCreated = remember { mutableStateOf(false) }
val trackPath = remember { mutableStateOf(Path()) }
val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// val soundPool = remember { mutableStateOf<SoundPool?>(null) }
// val beepSoundId = remember { mutableIntStateOf(0) }
// val aimTargetDifference = remember { mutableFloatStateOf(0f) }
// LaunchedEffect(item.azimuth, item.elevation, azimElev.first, azimElev.second) {
// val aimAzimuthRadians = azimElev.first.toDouble().toRadians()
// val aimElevationRadians = abs(min(azimElev.second, 0f)).toDouble().toRadians()
// // radius of 0.5 makes the aimTargetDifference range 0.0 to 1.0
// val radius = 0.5
// val aimX = sph2CartX(aimAzimuthRadians, aimElevationRadians, radius)
// val aimY = sph2CartY(aimAzimuthRadians, aimElevationRadians, radius)
// val satX = sph2CartX(item.azimuth, item.elevation, radius)
// val satY = sph2CartY(item.azimuth, item.elevation, radius)
// aimTargetDifference.floatValue = sqrt((satX - aimX).pow(2) + (satY - aimY).pow(2))
// val minPlaybackRate = 0.5f
// val maxPlaybackRate = 2.0f
// val playbackRate =
// maxPlaybackRate - (aimTargetDifference.floatValue / (maxPlaybackRate - minPlaybackRate))
// soundPool.value?.setRate(beepSoundId.intValue, playbackRate)
// }
//
// LaunchedEffect(aimTargetDifference.floatValue < aimThreshold) {
// if (aimTargetDifference.floatValue < aimThreshold) {
// view.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY)
// soundPool.value?.pause(beepSoundId.intValue)
// } else {
// soundPool.value?.resume(beepSoundId.intValue)
// }
// }
//
// LaunchedEffect(item.elevation > 0) {
// if(item.elevation > 0) {
// soundPool.value?.resume(beepSoundId.intValue)
// } else {
// soundPool.value?.pause(beepSoundId.intValue)
// }
// }
//
// DisposableEffect(Unit) {
// val audioAttributes = AudioAttributes.Builder()
// .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
// .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
// .build()
// soundPool.value = SoundPool.Builder()
// .setMaxStreams(1)
// .setAudioAttributes(audioAttributes)
// .build()
// beepSoundId.intValue = soundPool.value?.load(context, R.raw.beep, 1) ?: 0
// soundPool.value?.setOnLoadCompleteListener { soundPool, _, status ->
// if (status == 0) {
// soundPool.play(beepSoundId.intValue, 0.5f, 0.5f, 0, -1, 1f)
// }
// }
// onDispose {
// soundPool.value?.release()
// }
// }
Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = (size.minDimension / 2f) * 0.95f
if (!trackCreated.value) {
trackPath.value = createTrackPath(items, radius)
trackEffect.value = createTrackEffect(trackPath.value)
trackCreated.value = true
}
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
if (shouldShowSweep) {
drawSweep(center, sweepDegrees.floatValue, radius, trackColor)
}
drawRadar(radius, radarColor, strokeWidth, 3)
drawInfo(radius, trackColor, measurer, 3)
translate(center.x, center.y) {
drawTrack(trackPath.value, trackEffect.value, aimColor, trackColor)
if (item.elevation > 0) {
drawPosition(item, radius, animScale.value, trackColor)
}
if (shouldUseCompass) {
drawAim(azimElev.first, azimElev.second, radius, strokeWidth, aimColor)
}
}
sweepDegrees.floatValue = (sweepDegrees.floatValue + 360 / 12.0f / 60) % 360
}
}
}
private fun DrawScope.drawRadar(radius: Float, color: Color, width: Float, circles: Int) {
for (i in 0 until circles) {
val circleRadius = radius - radius / circles.toFloat() * i.toFloat()
drawCircle(color, circleRadius, style = Stroke(width))
}
drawLine(color, center.copy(x = center.x - radius), center.copy(x = center.x + radius), width)
drawLine(color, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width)
}
private fun DrawScope.drawInfo(radius: Float, color: Color, measurer: TextMeasurer, circles: Int) {
for (i in 0 until circles) {
val textY = (radius - radius / circles * i) - 32f
val textDeg = " ${(90 / circles) * (circles - i)}°"
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp))
}
}
private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) {
drawPath(path, color, style = Stroke(6f))
drawPath(path, effectColor, style = Stroke(pathEffect = effect))
}
private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) {
val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble())
val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color, 16f, center.copy(satX, -satY))
drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, center.copy(satX, -satY))
}
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) {
val size = 36f
val azimRadians = azim.toDouble().toRadians()
val tempElevRadians = elev.toDouble().toRadians()
val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians
val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble())
val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble())
try {
drawLine(color, center.copy(aimX - size, -aimY), center.copy(aimX + size, -aimY), width)
drawLine(color, center.copy(aimX, -aimY - size), center.copy(aimX, -aimY + size), width)
drawCircle(color, size / 2, center.copy(aimX, -aimY), style = Stroke(width))
} catch (exception: Exception) {
println(exception)
}
}
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
val colorStops = listOf(0.64f, 0.995f, 1f)
val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
}
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
val trackPath = Path()
positions.forEachIndexed { index, satPos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble())
if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY)
}
return trackPath
}
private fun createTrackEffect(trackPath: Path): PathEffect {
val shape = Path()
val shapeRadius = 24f
val angle = 120.0.toRadians()
shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
for (i in 1 until 3) {
val x = (shapeRadius * cos(angle - angle * i)).toFloat()
val y = (shapeRadius * sin(angle - angle * i)).toFloat()
shape.lineTo(x, y)
}
shape.close()
val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length
val advance = trackLength / 2f
val phase = trackLength / 4f
return PathEffect.stampedPathEffect(shape, advance, phase, StampedPathEffectStyle.Rotate)
}
private fun sph2CartX(azim: Double, elev: Double, r: Double): Float {
val radius = r * (PI_2 - elev) / PI_2
return (radius * cos(PI_2 - azim)).toFloat()
}
private fun sph2CartY(azim: Double, elev: Double, r: Double): Float {
val radius = r * (PI_2 - elev) / PI_2
return (radius * sin(PI_2 - azim)).toFloat()
}
@@ -1,244 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.radar
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.data.framework.WithoutExtParams
import com.rtbishop.look4sat.data.framework.BluetoothReporter
import com.rtbishop.look4sat.data.framework.BtService
import com.rtbishop.look4sat.data.framework.ExtendedParams
import com.rtbishop.look4sat.data.framework.NetworkReporter
import com.rtbishop.look4sat.domain.model.SatRadio
import com.rtbishop.look4sat.domain.predict.OrbitalObject
import com.rtbishop.look4sat.domain.predict.OrbitalPass
import com.rtbishop.look4sat.domain.predict.OrbitalPos
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.domain.utility.round
import com.rtbishop.look4sat.domain.utility.toDegrees
import com.rtbishop.look4sat.domain.utility.toTimerString
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class RadarViewModel(
private val savedStateHandle: SavedStateHandle,
private val bluetoothReporter: BluetoothReporter,
private val networkReporter: NetworkReporter,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo,
private val sensorsRepo: ISensorsRepo,
private val addToCalendar: IAddToCalendar
) : ViewModel() {
private val _uiState = MutableStateFlow(
RadarState(
currentPass = null,
currentTime = "00:00:00",
isCurrentTimeAos = true,
orientationValues = sensorsRepo.orientation.value,
orbitalPos = null,
satTrack = emptyList(),
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
selectedTransmitterUuid = null,
selectedFrequency = null,
transmitters = emptyList(),
sendAction = ::handleAction,
)
)
private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
val uiState: StateFlow<RadarState> = _uiState
init {
if (settingsRepo.otherSettings.value.stateOfSensors) {
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data ->
val orientationValues = Pair(data.first + magDeclination, data.second)
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
}
viewModelScope.launch {
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
val passes = satelliteRepo.passes.value
val pass = passes.find { pass -> pass.catNum == catNum && pass.aosTime == aosTime }
val currentPass = pass ?: passes.firstOrNull()
currentPass?.let { satPass ->
_uiState.update { it.copy(currentPass = satPass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
while (isActive) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
when {
satPass.isDeepSpace -> {
val time = 0L.toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) }
}
satPass.aosTime > timeNow -> {
val time = satPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true) }
}
else -> {
val time = satPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) }
}
}
processRadios(transmitters, satPass.orbitalObject, timeNow)
sendPassData(satPass, pos, satPass.orbitalObject)
sendPassDataBT(pos)
delay(1000)
}
}
}
}
override fun onCleared() {
sensorsRepo.disableSensor()
super.onCleared()
}
private fun handleAction(action: RadarAction) {
when (action) {
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
is RadarAction.SelectTransmitter -> { _uiState.update { it.copy(selectedTransmitterUuid = action.uuid) } }
}
}
private suspend fun sendPassData(orbitalPass: OrbitalPass, orbitalPos: OrbitalPos, orbitalObject: OrbitalObject) {
var track: List<OrbitalPos> = emptyList()
if (!orbitalPass.isDeepSpace) {
track = satelliteRepo.getTrack(
orbitalObject, stationPos, orbitalPass.aosTime, orbitalPass.losTime
)
}
_uiState.update { it.copy(orbitalPos = orbitalPos, satTrack = track) }
viewModelScope.launch {
if (settingsRepo.rcSettings.value.rotatorState) {
val server = settingsRepo.rcSettings.value.rotatorAddress
val port = settingsRepo.rcSettings.value.rotatorPort.toInt()
val format = settingsRepo.rcSettings.value.rotatorFormat
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
val elevation = orbitalPos.elevation.toDegrees().round(2)
networkReporter.reportRotation(format, azimuth, elevation, ExtendedParams(server, port))
}
if (settingsRepo.rcSettings.value.frequencyState) {
_uiState.value.selectedFrequency?.let { freq ->
val server = settingsRepo.rcSettings.value.frequencyAddress
val port = settingsRepo.rcSettings.value.frequencyPort.toInt()
val format = settingsRepo.rcSettings.value.frequencyFormat
networkReporter.reportFrequency(format,freq, ExtendedParams(server, port))
}
}
}
}
private fun sendPassDataBT(orbitalPos: OrbitalPos) {
viewModelScope.launch {
if (settingsRepo.rcSettings.value.bluetoothRotatorState) {
val btRotatorDevice = settingsRepo.rcSettings.value.bluetoothRotatorAddress
if (bluetoothReporter.isConnected(BtService.ROTATOR)) {
val format = settingsRepo.rcSettings.value.bluetoothRotatorFormat
val azimuth = orbitalPos.azimuth.toDegrees().round(0)
val elevation = orbitalPos.elevation.toDegrees().round(0)
bluetoothReporter.reportRotation(format, azimuth, elevation, WithoutExtParams(0))
} else if (!bluetoothReporter.isConnecting(BtService.ROTATOR)) {
// Log.i("BTReporter", "BTReporter (rotator): Attempting to connect...")
bluetoothReporter.connect(BtService.ROTATOR,btRotatorDevice)
}
}
if (settingsRepo.rcSettings.value.bluetoothFrequencyState) {
val btFrequencyDevice = settingsRepo.rcSettings.value.bluetoothFrequencyAddress
if (bluetoothReporter.isConnected(BtService.FREQUENCY)) {
val format = settingsRepo.rcSettings.value.bluetoothFrequencyFormat
val frequency = _uiState.value.selectedFrequency
frequency?.let { freq ->
bluetoothReporter.reportFrequency(format, freq, WithoutExtParams(0))
}
} else if (!bluetoothReporter.isConnecting(BtService.FREQUENCY)) {
// Log.i("BTReporter", "BTReporter (frequency): Attempting to connect...")
bluetoothReporter.connect(BtService.FREQUENCY, btFrequencyDevice)
}
}
}
}
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
_uiState.update { state ->
if (!settingsRepo.rcSettings.value.frequencyState && !settingsRepo.rcSettings.value.bluetoothFrequencyState) {
return@update state.copy(
transmitters = transmitters,
selectedTransmitterUuid = null,
selectedFrequency = null
)
}
val selectedUuid = state.selectedTransmitterUuid ?: transmitters.firstOrNull()?.uuid
val selectedRadio = transmitters.firstOrNull { it.uuid == selectedUuid }
val freq = selectedRadio?.let { radio ->
val low = radio.downlinkLow
val high = radio.downlinkHigh
when {
low != null && high != null ->
(low + high) / 2
low != null ->
low
else -> null
}
}
state.copy(
transmitters = transmitters,
selectedTransmitterUuid = selectedUuid,
selectedFrequency = freq
)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer {
val container = (this[applicationKey] as MainApplication).container
RadarViewModel(
createSavedStateHandle(),
container.provideBluetoothReporter(),
container.provideNetworkReporter(),
container.satelliteRepo,
container.settingsRepo,
container.provideSensorsRepo(),
container.provideAddToCalendar()
)
}
}
}
}
@@ -1,100 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.satellites
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.common.SharedDialog
@Preview(showBackground = true)
@Composable
private fun MultiTypesDialogPreview() {
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
}
@Composable
fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) {
val selected = remember { mutableStateListOf<String>().apply { addAll(types) } }
val select = { type: String ->
if (selected.contains(type)) selected.remove(type) else selected.add(type)
}
val onAccept = { accept(selected.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
modifier = Modifier
.fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background),
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allTypes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { select(item) }
) {
Text(
text = "${index + 1}).",
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary
)
Text(
text = item,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = selected.contains(item),
onCheckedChange = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
}
}
@@ -1,277 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.satellites
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.model.SatItem
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.Screen
import com.rtbishop.look4sat.presentation.common.CardLoadingIndicator
import com.rtbishop.look4sat.presentation.common.EmptyListCard
import com.rtbishop.look4sat.presentation.common.IconCard
import com.rtbishop.look4sat.presentation.common.InfoDialog
import com.rtbishop.look4sat.presentation.common.PrimaryIconCard
import com.rtbishop.look4sat.presentation.common.TopBar
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
import com.rtbishop.look4sat.presentation.common.layoutPadding
fun NavGraphBuilder.satellitesDestination(navigateUp: () -> Unit) {
composable(Screen.Satellites.route) {
val viewModel = viewModel(
modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, navigateUp)
}
}
@Composable
private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
val toggleDialog = { uiState.takeAction(SatellitesAction.ToggleTypesDialog) }
if (uiState.isDialogShown) {
MultiTypesDialog(allTypes = uiState.typesList, types = uiState.currentTypes, toggleDialog) {
uiState.takeAction(SatellitesAction.SelectTypes(it))
}
}
if (uiState.shouldSeeWarning) {
InfoDialog(
stringResource(R.string.sat_warning_title),
stringResource(R.string.sat_warning_message)
) {
uiState.takeAction(SatellitesAction.DismissWarning)
}
}
val unselectAll = { uiState.takeAction(SatellitesAction.UnselectAll) }
val selectAll = { uiState.takeAction(SatellitesAction.SelectAll) }
val setQuery = { newQuery: String -> uiState.takeAction(SatellitesAction.SearchFor(newQuery)) }
val saveSelection = { uiState.takeAction(SatellitesAction.SaveSelection).also { navigateUp() } }
val primCardCd = stringResource(R.string.btn_accept)
val primCardMod = Modifier.semantics { contentDescription = primCardCd }
val clearAllCd = stringResource(R.string.sat_clear_all)
val clearAllMod = Modifier.semantics { contentDescription = clearAllCd }
val selectAllCd = stringResource(R.string.sat_select_all)
val selectAllMod = Modifier.semantics { contentDescription = selectAllCd }
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
TopBar {
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
}
TopBar {
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
}
} else {
TopBar {
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
}
}
ElevatedCard(modifier = Modifier.fillMaxSize()) {
val emptyMessage = stringResource(R.string.sat_empty_list_message)
when {
uiState.isLoading -> CardLoadingIndicator()
uiState.itemsList.isEmpty() -> EmptyListCard(message = emptyMessage)
else -> SatellitesCard(uiState.itemsList) { id, isTicked ->
uiState.takeAction(SatellitesAction.SelectSingle(id, isTicked))
}
}
}
}
}
@Composable
private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") }
val setNewQuery = { newValue: String ->
currentQuery.value = newValue
setQuery(newValue)
}
ElevatedCard(modifier = modifier.height(48.dp)) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp)
) {
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField(
value = currentQuery.value,
onValueChange = { setNewQuery(it) },
singleLine = true,
modifier = modifier.padding(start = 12.dp),
textStyle = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (currentQuery.value.isEmpty()) {
Text(
text = stringResource(id = R.string.sat_search_hint),
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
innerTextField()
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
IconButton(onClick = { setNewQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear)
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
}
}
}
}
@Composable
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
ElevatedCard(modifier = modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(48.dp)
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_tags),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.sat_type_hint, typesText),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee()
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun SatellitePreview() {
val satItem = SatItem(44444, "Ultra Super Mega long satellite name", true)
MainTheme { Satellite(item = satItem, onSelected = { _, _ -> run {} }, modifier = Modifier) }
}
@Composable
private fun Satellite(item: SatItem, onSelected: (Int, Boolean) -> Unit, modifier: Modifier) {
val passSatId = stringResource(id = R.string.pass_satId, item.catnum)
Surface(
color = MaterialTheme.colorScheme.background,
modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) {
Surface(modifier = Modifier.padding(bottom = 1.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
) {
Text(
text = "$passSatId - ", color = MaterialTheme.colorScheme.primary
)
Text(
text = item.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = item.isSelected,
onCheckedChange = null,
modifier = Modifier.padding(start = 6.dp)
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun SatellitesPreview() {
val entries = listOf(
SatItem(8888, "Meteor", false),
SatItem(44444, "ISS", true),
SatItem(88888, "Starlink", false)
)
MainTheme { SatellitesCard(entries) { _, _ -> run {} } }
}
@Composable
fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
}
@@ -1,41 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.satellites
import com.rtbishop.look4sat.domain.model.SatItem
data class SatellitesState(
val isDialogShown: Boolean,
val isLoading: Boolean,
val shouldSeeWarning: Boolean,
val itemsList: List<SatItem>,
val currentTypes: List<String>,
val typesList: List<String>,
val takeAction: (SatellitesAction) -> Unit
)
sealed class SatellitesAction {
data object DismissWarning : SatellitesAction()
data object SaveSelection : SatellitesAction()
data class SearchFor(val query: String) : SatellitesAction()
data object SelectAll : SatellitesAction()
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction()
data class SelectTypes(val types: List<String>) : SatellitesAction()
data object ToggleTypesDialog : SatellitesAction()
data object UnselectAll : SatellitesAction()
}
@@ -1,114 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.satellites
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SatellitesViewModel(
private val selectionRepo: ISelectionRepo,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val defaultTypes = selectionRepo.getCurrentTypes()
private val _uiState = MutableStateFlow(
SatellitesState(
isDialogShown = false,
isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
itemsList = emptyList(),
currentTypes = defaultTypes,
typesList = selectionRepo.getTypesList(),
takeAction = ::handleAction
)
)
val uiState: StateFlow<SatellitesState> = _uiState
init {
viewModelScope.launch {
delay(1000)
selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes)
selectionRepo.getEntriesFlow().collectLatest { items ->
_uiState.update { it.copy(isLoading = false, itemsList = items) }
}
}
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
}
}
}
private fun handleAction(action: SatellitesAction) {
when (action) {
SatellitesAction.DismissWarning -> settingsRepo.setWarningDismissed()
SatellitesAction.SaveSelection -> saveSelection()
is SatellitesAction.SearchFor -> searchFor(action.query)
SatellitesAction.SelectAll -> selectAll(true)
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
is SatellitesAction.SelectTypes -> selectTypes(action.types)
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
SatellitesAction.UnselectAll -> selectAll(false)
}
}
private fun saveSelection() = viewModelScope.launch { selectionRepo.saveSelection() }
private fun searchFor(query: String) = viewModelScope.launch { selectionRepo.setQuery(query) }
private fun selectAll(selectAll: Boolean) = viewModelScope.launch {
selectionRepo.setSelection(selectAll)
}
private fun selectSingle(id: Int, isTicked: Boolean) = viewModelScope.launch {
selectionRepo.setSelection(listOf(id), isTicked.not())
}
private fun selectTypes(types: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types)
_uiState.value = _uiState.value.copy(currentTypes = types, isDialogShown = false)
}
private fun toggleTypesDialog() {
val currentDialogState = _uiState.value.isDialogShown
_uiState.value = _uiState.value.copy(isDialogShown = currentDialogState.not())
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer {
val container = (this[applicationKey] as MainApplication).container
SatellitesViewModel(container.selectionRepo, container.settingsRepo)
}
}
}
}
@@ -1,484 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.settings
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.common.SharedDialog
import com.rtbishop.look4sat.presentation.common.CardButton
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.rtbishop.look4sat.presentation.LocalSpacing
import com.rtbishop.look4sat.domain.model.RCSettings
@Preview(showBackground = true)
@Composable
private fun PositionDialogPreview() {
MainTheme { PositionDialog(0.0, 0.0, {}) { _, _ -> } }
}
@Composable
fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double, Double) -> Unit) {
val latValue = rememberSaveable { mutableStateOf(lat.toString()) }
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
val titleText = stringResource(id = R.string.prefs_station_title)
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }
)
}
}
private fun saveValues(latValue: String, lonValue: String, save: (Double, Double) -> Unit) {
val latitude = latValue.toDoubleOrNull() ?: 0.0
val longitude = lonValue.toDoubleOrNull() ?: 0.0
val newLatitude = if (latitude > 90) 90.0 else if (latitude < -90) -90.0 else latitude
val newLongitude = if (longitude > 180) 180.0 else if (longitude < -180) -180.0 else longitude
save(newLatitude, newLongitude)
}
@Preview(showBackground = true)
@Composable
private fun LocatorDialogPreview() {
MainTheme { LocatorDialog("IO91vl", {}) { } }
}
@Composable
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) }
val onAccept = { save(locator.value).also { dismiss() } }
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
OutlinedTextField(
value = locator.value,
onValueChange = { locator.value = it },
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) }
)
}
}
@Preview(showBackground = true)
@Composable
private fun TransceiversDialogPreview() {
MainTheme {
DataSourcesDialog(
useCustomTle = true,
useCustomTransceivers = true,
tleUrl = "https://example.com/tle.txt",
transceiversUrl = "https://example.com/tx.json",
onImportTle = {},
onImportTransceivers = {},
onDismiss = {},
onSave = { _, _, _, _ -> }
)
}
}
@Composable
fun DataSourcesDialog(
useCustomTle: Boolean,
useCustomTransceivers: Boolean,
tleUrl: String,
transceiversUrl: String,
onImportTle: () -> Unit,
onImportTransceivers: () -> Unit,
onDismiss: () -> Unit,
onSave: (Boolean, Boolean, String, String) -> Unit
) {
val padding = LocalSpacing.current.large
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
val onAccept = {
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
onDismiss()
}
val onCancel = { onDismiss() }
SharedDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onCancel,
onAccept = onAccept,
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CardButton(
onClick = {
onImportTle()
onDismiss()
},
text = "TLE (3LE)\nR4UAB (.txt)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = {
onImportTransceivers()
onDismiss()
},
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
Switch(
checked = isEnabledCustomTle.value,
onCheckedChange = { isEnabledCustomTle.value = it }
)
}
OutlinedTextField(
value = urlTle.value,
onValueChange = { urlTle.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
)
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
Switch(
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { isEnabledCustomTransceivers.value = it }
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewNetworkOutputDialog() {
MainTheme {
NetworkOutputDialog(
initialSettings = RCSettings(
rotatorState = false,
rotatorAddress = "127.0.0.1",
rotatorPort = "4533",
rotatorFormat = $$"P $AZ $EL",
frequencyState = false,
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"set_freq $FREQ",
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"W$AZ $EL",
bluetoothRotatorName = "Default",
bluetoothRotatorAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyState = false,
bluetoothFrequencyAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = $$"FA$FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> }
)
}
}
@Composable
fun NetworkOutputDialog(
initialSettings: RCSettings,
onDismiss: () -> Unit,
onSave: (
Boolean, String, String, String,
Boolean, String, String, String
) -> Unit
) {
val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.rotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.rotatorAddress) }
val rotatorPort = rememberSaveable { mutableStateOf(initialSettings.rotatorPort) }
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.rotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.frequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.frequencyAddress) }
val frequencyPort = rememberSaveable { mutableStateOf(initialSettings.frequencyPort) }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val onAccept = {
onSave(
rotatorState.value,
rotatorAddress.value,
rotatorPort.value,
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyPort.value,
frequencyFormat.value
)
onDismiss()
}
SharedDialog(
title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss,
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_net_rotator_switch))
Switch(
checked = rotatorState.value,
onCheckedChange = { rotatorState.value = it }
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = rotatorAddress.value,
onValueChange = { rotatorAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_ip_hint)) },
modifier = Modifier.weight(1.5f),
enabled = rotatorState.value
)
OutlinedTextField(
value = rotatorPort.value,
onValueChange = { rotatorPort.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_port_hint)) },
modifier = Modifier.weight(1f),
enabled = rotatorState.value
)
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = rotatorFormat.value,
onValueChange = { rotatorFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_format_hint)) },
modifier = Modifier.fillMaxWidth(),
enabled = rotatorState.value
)
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_net_frequency_switch))
Switch(
checked = frequencyState.value,
onCheckedChange = { frequencyState.value = it }
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = frequencyAddress.value,
onValueChange = { frequencyAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_ip_hint)) },
modifier = Modifier.weight(1.5f),
enabled = frequencyState.value
)
OutlinedTextField(
value = frequencyPort.value,
onValueChange = { frequencyPort.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_port_hint)) },
modifier = Modifier.weight(1f),
enabled = frequencyState.value
)
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyFormat.value,
onValueChange = { frequencyFormat.value = it },
maxLines = 2,
label = { Text(stringResource(R.string.prefs_net_frequency_format_hint)) },
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewBluetoothOutputDialog() {
MainTheme {
BluetoothOutputDialog(
initialSettings = RCSettings(
rotatorState = false,
rotatorAddress = "127.0.0.1",
rotatorPort = "4533",
rotatorFormat = $$"P $AZ $EL",
frequencyState = false,
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"set_freq $FREQ",
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"W$AZ $EL",
bluetoothRotatorName = "Default",
bluetoothRotatorAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyState = false,
bluetoothFrequencyAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = $$"FA$FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _ -> }
)
}
}
@Composable
fun BluetoothOutputDialog(
initialSettings: RCSettings,
onDismiss: () -> Unit,
onSave: (
Boolean, String, String,
Boolean, String, String
) -> Unit
) {
val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = {
onSave(
rotatorState.value,
rotatorAddress.value,
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyFormat.value
)
onDismiss()
}
SharedDialog(
title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss,
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_bt_rotator_switch))
Switch(
checked = rotatorState.value,
onCheckedChange = { rotatorState.value = it }
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = rotatorAddress.value,
onValueChange = { rotatorAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_rotator_device_hint)) },
modifier = Modifier.weight(1.5f),
enabled = rotatorState.value
)
OutlinedTextField(
value = rotatorFormat.value,
onValueChange = { rotatorFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_rotator_output_hint)) },
modifier = Modifier.weight(1f),
enabled = rotatorState.value
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_bt_frequency_switch))
Switch(
checked = frequencyState.value,
onCheckedChange = { frequencyState.value = it }
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = frequencyAddress.value,
onValueChange = { frequencyAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_device_hint)) },
modifier = Modifier.weight(1.5f),
enabled = frequencyState.value
)
OutlinedTextField(
value = frequencyFormat.value,
onValueChange = { frequencyFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_output_hint)) },
modifier = Modifier.weight(1f),
enabled = frequencyState.value
)
}
}
}
}
@@ -1,628 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.settings
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.model.OtherSettings
import com.rtbishop.look4sat.domain.predict.GeoPos
import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.Screen
import com.rtbishop.look4sat.presentation.common.CardButton
import com.rtbishop.look4sat.presentation.common.IconCard
import com.rtbishop.look4sat.presentation.common.PrimaryIconCard
import com.rtbishop.look4sat.presentation.common.ScreenColumn
import com.rtbishop.look4sat.presentation.common.TopBar
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
fun NavGraphBuilder.settingsDestination() {
composable(Screen.Settings.route) {
val viewModel = viewModel(
modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsScreen(uiState)
}
}
@Composable
private fun SettingsScreen(uiState: SettingsState) {
val locationContract = ActivityResultContracts.RequestMultiplePermissions()
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationPermCoarse = Manifest.permission.ACCESS_COARSE_LOCATION
val locationPermFine = Manifest.permission.ACCESS_FINE_LOCATION
val locationRequest = rememberLauncherForActivityResult(locationContract) { permissions ->
when {
permissions[locationPermFine] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
permissions[locationPermCoarse] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
else -> uiState.sendSystemAction(SystemAction.ShowToast(locationError))
}
}
val contentContract = ActivityResultContracts.GetContent()
val contentRequestForTle = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTLEFromFile(uri.toString())) }
}
val contentRequestForTransceivers = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTransceiversFromFile(uri.toString())) }
}
// Position settings
val posSettings = uiState.positionSettings
val setGpsPos = { locationRequest.launch(arrayOf(locationPermCoarse, locationPermFine)) }
val setGeoPos = { lat: Double, lon: Double ->
uiState.sendAction(SettingsAction.SetGeoPosition(lat, lon))
}
val setQthPos = { locator: String ->
uiState.sendAction(SettingsAction.SetQthPosition(locator))
}
val dismissPos = { uiState.sendAction(SettingsAction.DismissPosMessages) }
val posDialogState = rememberSaveable { mutableStateOf(false) }
val showPosDialog = { posDialogState.value = posDialogState.value.not() }
if (posDialogState.value) {
PositionDialog(posSettings.stationPos.latitude, posSettings.stationPos.longitude, showPosDialog, setGeoPos)
}
val locDialogState = rememberSaveable { mutableStateOf(false) }
val showLocDialog = { locDialogState.value = locDialogState.value.not() }
if (locDialogState.value) {
LocatorDialog(posSettings.stationPos.qthLocator, showLocDialog, setQthPos)
}
// Data sources dialog
val dataSourcesDialogState = rememberSaveable { mutableStateOf(false) }
val showDataSourcesDialog = { dataSourcesDialogState.value = true }
val dismissDataSourcesDialog = { dataSourcesDialogState.value = false }
if (dataSourcesDialogState.value) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
onImportTle = { contentRequestForTle.launch("*/*") },
onImportTransceivers = { contentRequestForTransceivers.launch("*/*") },
onDismiss = dismissDataSourcesDialog,
onSave = {
useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
if (!useCustomTle || tleUrl.isNotBlank()) {
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTle(useCustomTle))
uiState.sendDataSourcesAction(DataSourcesAction.SetTleUrl(tleUrl))
}
if (!useCustomTransceivers || transceiversUrl.isNotBlank()) {
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTransceivers(useCustomTransceivers))
uiState.sendDataSourcesAction(DataSourcesAction.SetTransceiversUrl(transceiversUrl))
}
if (useCustomTle || useCustomTransceivers) {
uiState.sendAction(SettingsAction.UpdateFromWeb)
}
}
)
}
// Data settings
val dataSettings = uiState.dataSettings
val updateFromWeb: () -> Unit = { uiState.sendAction(SettingsAction.UpdateFromWeb) }
val clearAllData: () -> Unit = { uiState.sendAction(SettingsAction.ClearAllData) }
// RC settings
val rcSettings = uiState.rcSettings
val setRotatorState = { value: Boolean -> uiState.sendRCAction(RCAction.SetRotatorState(value)) }
val setRotatorAddress = { value: String -> uiState.sendRCAction(RCAction.SetRotatorAddress(value)) }
val setRotatorPort = { value: String -> uiState.sendRCAction(RCAction.SetRotatorPort(value)) }
val setRotatorFormat = { value: String -> uiState.sendRCAction(RCAction.SetRotatorFormat(value)) }
val setFrequencyState = { value: Boolean -> uiState.sendRCAction(RCAction.SetFrequencyState(value)) }
val setFrequencyAddress = { value: String -> uiState.sendRCAction(RCAction.SetFrequencyAddress(value)) }
val setFrequencyPort = { value: String -> uiState.sendRCAction(RCAction.SetFrequencyPort(value)) }
val setFrequencyFormat = { value: String -> uiState.sendRCAction(RCAction.SetFrequencyFormat(value)) }
val setBluetoothRotatorState = { value: Boolean -> uiState.sendRCAction(RCAction.SetBluetoothRotatorState(value)) }
val setBluetoothRotatorAddress = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothRotatorAddress(value)) }
val setBluetoothRotatorFormat = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothRotatorFormat(value)) }
val setBluetoothFrequencyState = { value: Boolean -> uiState.sendRCAction(RCAction.SetBluetoothFrequencyState(value)) }
val setBluetoothFrequencyAddress = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothFrequencyAddress(value)) }
val setBluetoothFrequencyFormat = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothFrequencyFormat(value)) }
// Network data output
val networkDialogState = rememberSaveable { mutableStateOf(false) }
val showNetworkDialog = { networkDialogState.value = true }
val dismissNetworkDialog = { networkDialogState.value = false }
if (networkDialogState.value) {
NetworkOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissNetworkDialog,
onSave = { rotatorState,
rotatorAddress,
rotatorPort,
rotatorFormat,
frequencyState,
frequencyAddress,
frequencyPort,
frequencyFormat ->
setRotatorState(rotatorState)
setRotatorAddress(rotatorAddress)
setRotatorPort(rotatorPort)
setRotatorFormat(rotatorFormat)
setFrequencyState(frequencyState)
setFrequencyAddress(frequencyAddress)
setFrequencyPort(frequencyPort)
setFrequencyFormat(frequencyFormat)
}
)
}
// Bluetooth data output
val bluetoothDialogState = rememberSaveable { mutableStateOf(false) }
val bluetoothContract = ActivityResultContracts.RequestPermission()
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
val bluetoothPerm = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> Manifest.permission.BLUETOOTH
else -> Manifest.permission.BLUETOOTH_CONNECT
}
val bluetoothRequest = rememberLauncherForActivityResult(bluetoothContract) { isGranted ->
if (!isGranted)
{
uiState.sendRCAction(RCAction.SetBluetoothRotatorState(false))
uiState.sendRCAction(RCAction.SetBluetoothFrequencyState(false))
uiState.sendSystemAction(SystemAction.ShowToast(bluetoothError))
}
else { bluetoothDialogState.value = true }
}
val showBluetoothDialog = { bluetoothRequest.launch(bluetoothPerm) }
val dismissBluetoothDialog = { bluetoothDialogState.value = false }
if (bluetoothDialogState.value) {
BluetoothOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissBluetoothDialog,
onSave = { rotatorState,
rotatorAddress,
rotatorFormat,
frequencyState,
frequencyAddress,
frequencyFormat ->
setBluetoothRotatorState(rotatorState)
setBluetoothRotatorAddress(rotatorAddress)
setBluetoothRotatorFormat(rotatorFormat)
setBluetoothFrequencyState(frequencyState)
setBluetoothFrequencyAddress(frequencyAddress)
setBluetoothFrequencyFormat(frequencyFormat)
}
)
}
// Other settings
val otherSettings = uiState.otherSettings
val toggleUtc = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleUtc(value)) }
val toggleUpdate = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleUpdate(value)) }
val toggleSweep = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleSweep(value)) }
val toggleSensor = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleSensor(value)) }
val uriHandler = LocalUriHandler.current
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)
val gitHubTitle = stringResource(R.string.prefs_github_title)
val gitHubUrl = stringResource(R.string.prefs_github_url)
val licenseUrl = stringResource(R.string.prefs_license_url)
val privacyUrl = stringResource(R.string.prefs_privacy_url)
ScreenColumn(
topBar = { isVerticalLayout ->
if (isVerticalLayout) {
TopBar {
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
}
TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
}
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
}
} else {
TopBar {
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
}
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
}
}
}
) { _ ->
LazyVerticalGrid(
columns = GridCells.Adaptive(320.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clip(MaterialTheme.shapes.medium)
) {
item {
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction)
}
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) }
item(span = { GridItemSpan(maxLineSpan) }) { OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }) }
item { OtherCard(otherSettings, toggleUtc, toggleUpdate, toggleSweep, toggleSensor) }
item { CardCredits() }
}
}
}
@Preview(showBackground = true)
@Composable
private fun LocationCardPreview() = MainTheme {
val stationPos = GeoPos(0.0, 0.0, 0.0, "IO91vl", 0L)
val settings = PositionSettings(true, stationPos, 0)
LocationCard(settings = settings, setGpsPos = {}, showPosDialog = {}, {}, {}) {}
}
@Composable
private fun LocationCard(
settings: PositionSettings,
setGpsPos: () -> Unit,
showPosDialog: () -> Unit,
showLocDialog: () -> Unit,
dismissPosMessage: () -> Unit,
sendSystemAction: (SystemAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(id = R.string.prefs_loc_title),
color = MaterialTheme.colorScheme.primary
)
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(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}")
}
Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
CardButton(
onClick = setGpsPos,
text = stringResource(id = R.string.prefs_loc_gps_title),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(6.dp))
CardButton(
onClick = showPosDialog,
text = stringResource(id = R.string.prefs_loc_input_title),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(6.dp))
CardButton(
onClick = showLocDialog,
text = stringResource(id = R.string.prefs_loc_qth_title),
modifier = Modifier.weight(1f)
)
}
}
}
if (settings.messageResId != 0) {
val errorString = stringResource(id = settings.messageResId)
LaunchedEffect(key1 = settings.messageResId) {
sendSystemAction(SystemAction.ShowToast(errorString))
dismissPosMessage()
}
}
}
@Preview(showBackground = true)
@Composable
private fun DataCardPreview() = MainTheme {
val settings = DataSettings(true, 5000, 2500, 0L)
DataCard(settings = settings, {}, {}, {})
}
@Composable
private fun DataCard(
settings: DataSettings,
updateFromWeb: () -> Unit,
clearAllData: () -> Unit,
showDataSourcesDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(id = R.string.prefs_data_title),
color = MaterialTheme.colorScheme.primary
)
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal))
Text(text = stringResource(R.string.prefs_data_radios, settings.radiosTotal))
}
Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CardButton(
onClick = updateFromWeb,
text = stringResource(id = R.string.prefs_data_update),
modifier = Modifier.weight(1f)
)
CardButton(
onClick = showDataSourcesDialog,
text = stringResource(id = R.string.prefs_data_import),
modifier = Modifier.weight(1f)
)
CardButton(
onClick = clearAllData,
text = stringResource(id = R.string.prefs_data_clear),
modifier = Modifier.weight(1f)
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}) }
@Composable
private fun OutputCard(
onNetworkClick: () -> Unit,
onBluetoothClick: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_data_output_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CardButton(
onClick = onNetworkClick,
text = stringResource(id = R.string.prefs_net_output),
modifier = Modifier.weight(1f)
)
CardButton(
onClick = onBluetoothClick,
text = stringResource(id = R.string.prefs_bt_output),
modifier = Modifier.weight(1f)
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun OtherCardPreview() = MainTheme {
val values = OtherSettings(
stateOfAutoUpdate = true,
stateOfSensors = true,
stateOfSweep = true,
stateOfUtc = false,
stateOfLightTheme = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values, {}, {}, {}, {})
}
@Composable
private fun OtherCard(
settings: OtherSettings,
toggleUtc: (Boolean) -> Unit,
toggleUpdate: (Boolean) -> Unit,
toggleSweep: (Boolean) -> Unit,
toggleSensor: (Boolean) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth().height(220.dp)) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_utc))
Switch(checked = settings.stateOfUtc, onCheckedChange = { toggleUtc(it) })
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_update))
Switch(checked = settings.stateOfAutoUpdate, onCheckedChange = { toggleUpdate(it) })
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_sweep))
Switch(checked = settings.stateOfSweep, onCheckedChange = { toggleSweep(it) })
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_sensors))
Switch(checked = settings.stateOfSensors, onCheckedChange = { toggleSensor(it) })
}
}
}
}
@Composable
private fun setUpdateTime(updateTime: Long): String {
val updateDate = if (updateTime != 0L) {
val timePattern = stringResource(id = R.string.prefs_updated_time)
SimpleDateFormat(timePattern, Locale.getDefault()).format(Date(updateTime))
} else {
stringResource(id = R.string.pass_time_placeholder)
}
return stringResource(id = R.string.prefs_updated_title, updateDate)
}
@Composable
private fun UpdateIndicator(isUpdating: Boolean, modifier: Modifier = Modifier) = if (isUpdating) {
LinearProgressIndicator(modifier = modifier.padding(start = 6.dp))
} else {
LinearProgressIndicator(
progress = { 0f },
drawStopIndicator = {},
modifier = modifier.padding(start = 6.dp)
)
}
@Preview(showBackground = true)
@Composable
private fun CardCreditsPreview() = MainTheme { CardCredits() }
@Composable
private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth().height(220.dp)) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxHeight()
) {
Text(
text = stringResource(id = R.string.prefs_outro_title),
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(id = R.string.prefs_outro_thanks)
)
Text(
text = stringResource(id = R.string.prefs_outro_license),
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version: String) {
ElevatedCard(modifier = modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(48.dp)
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_satellites),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.prefs_app_title, version),
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee()
)
}
}
}
@Composable
private fun BotCard(onClick: () -> Unit, resId: Int, text: String, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(48.dp)
.clickable { onClick() }) {
Spacer(Modifier)
Icon(painter = painterResource(id = resId), contentDescription = null)
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee()
)
}
}
}
@@ -1,93 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.settings
import com.rtbishop.look4sat.domain.model.OtherSettings
import com.rtbishop.look4sat.domain.model.RCSettings
import com.rtbishop.look4sat.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.domain.predict.GeoPos
import com.rtbishop.look4sat.domain.predict.OrbitalPass
data class PositionSettings(
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
)
data class DataSettings(
val isUpdating: Boolean, val entriesTotal: Int, val radiosTotal: Int, val timestamp: Long
)
data class SettingsState(
val nextPass: OrbitalPass,
val nextTime: String,
val isNextTimeAos: Boolean,
val appVersionName: String,
val positionSettings: PositionSettings,
val dataSettings: DataSettings,
val otherSettings: OtherSettings,
val rcSettings: RCSettings,
val dataSourcesSettings: DataSourcesSettings,
val sendAction: (SettingsAction) -> Unit,
val sendRCAction: (RCAction) -> Unit,
val sendSystemAction: (SystemAction) -> Unit,
val sendDataSourcesAction: (DataSourcesAction) -> Unit
)
sealed class SettingsAction {
data object SetGpsPosition : SettingsAction()
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction()
data class SetQthPosition(val locator: String) : SettingsAction()
data object DismissPosMessages : SettingsAction()
data object UpdateFromWeb : SettingsAction()
data class UpdateTLEFromFile(val uri: String) : SettingsAction()
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction()
data object ClearAllData : SettingsAction()
data class ToggleUtc(val value: Boolean) : SettingsAction()
data class ToggleUpdate(val value: Boolean) : SettingsAction()
data class ToggleSweep(val value: Boolean) : SettingsAction()
data class ToggleSensor(val value: Boolean) : SettingsAction()
data class ToggleLightTheme(val value: Boolean) : SettingsAction()
}
sealed class SystemAction {
data class ShowToast(val message: String) : SystemAction()
}
sealed class RCAction {
data class SetRotatorState(val value: Boolean) : RCAction()
data class SetRotatorAddress(val value: String) : RCAction()
data class SetRotatorPort(val value: String) : RCAction()
data class SetRotatorFormat(val value: String) : RCAction()
data class SetFrequencyState(val value: Boolean) : RCAction()
data class SetFrequencyAddress(val value: String) : RCAction()
data class SetFrequencyPort(val value: String) : RCAction()
data class SetFrequencyFormat(val value: String) : RCAction()
data class SetBluetoothRotatorState(val value: Boolean) : RCAction()
data class SetBluetoothRotatorFormat(val value: String) : RCAction()
data class SetBluetoothRotatorName(val value: String) : RCAction()
data class SetBluetoothRotatorAddress(val value: String) : RCAction()
data class SetBluetoothFrequencyState(val value: Boolean) : RCAction()
data class SetBluetoothFrequencyFormat(val value: String) : RCAction()
data class SetBluetoothFrequencyAddress(val value: String) : RCAction()
}
sealed class DataSourcesAction {
data class SetUseCustomTle(val value: Boolean) : DataSourcesAction()
data class SetUseCustomTransceivers(val value: Boolean) : DataSourcesAction()
data class SetTleUrl(val value: String) : DataSourcesAction()
data class SetTransceiversUrl(val value: String) : DataSourcesAction()
}
@@ -1,304 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.domain.predict.OrbitalPass
import com.rtbishop.look4sat.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.domain.usecase.IShowToast
import com.rtbishop.look4sat.domain.utility.toTimerString
import com.rtbishop.look4sat.presentation.common.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast
) : ViewModel() {
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
private val defaultDataSourcesSettings = DataSourcesSettings(
useCustomTLE = false,
useCustomTransceivers = false,
tleUrl = settingsRepo.dataSourcesSettings.value.tleUrl,
transceiversUrl = settingsRepo.dataSourcesSettings.value.transceiversUrl
)
private val _uiState = MutableStateFlow(
SettingsState(
nextTime = "00:00:00",
isNextTimeAos = true,
nextPass = getDefaultPass(),
appVersionName = settingsRepo.appVersionName,
positionSettings = defaultPosSettings,
dataSettings = defaultDataSettings,
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value,
dataSourcesSettings = defaultDataSourcesSettings,
sendAction = ::handleAction,
sendRCAction = ::handleAction,
sendSystemAction = ::handleAction,
sendDataSourcesAction = ::handleAction
)
)
private var processing: Job? = null
val uiState: StateFlow<SettingsState> = _uiState
init {
viewModelScope.launch {
satelliteRepo.passes.collectLatest { passes ->
processing?.cancelAndJoin()
processing = viewModelScope.launch {
while (isActive) {
val timeNow = System.currentTimeMillis()
val newPasses = satelliteRepo.processPasses(passes, timeNow)
setPassInfo(newPasses, timeNow)
delay(1000)
}
}
}
}
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, stationPos = geoPos
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
}
viewModelScope.launch {
settingsRepo.databaseState.collect { state ->
val newDataSettings = _uiState.value.dataSettings.copy(
isUpdating = false,
entriesTotal = state.numberOfSatellites,
radiosTotal = state.numberOfRadios,
timestamp = state.updateTimestamp
)
_uiState.update { it.copy(dataSettings = newDataSettings) }
}
}
viewModelScope.launch {
settingsRepo.rcSettings.collect { settings ->
_uiState.update { it.copy(rcSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.otherSettings.collect { settings ->
_uiState.update { it.copy(otherSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.dataSourcesSettings.collect { settings ->
_uiState.update { it.copy(dataSourcesSettings = settings) }
}
}
}
private fun handleAction(action: SettingsAction) {
when (action) {
SettingsAction.SetGpsPosition -> setGpsPosition()
is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude)
is SettingsAction.SetQthPosition -> setQthPosition(action.locator)
SettingsAction.DismissPosMessages -> dismissPosMessage()
SettingsAction.UpdateFromWeb -> updateFromWeb()
is SettingsAction.UpdateTLEFromFile -> updateTLEFromFile(action.uri)
is SettingsAction.UpdateTransceiversFromFile -> updateTransceiversFromFile(action.uri)
SettingsAction.ClearAllData -> clearAllData()
is SettingsAction.ToggleUtc -> settingsRepo.setStateOfUtc(action.value)
is SettingsAction.ToggleUpdate -> settingsRepo.setStateOfAutoUpdate(action.value)
is SettingsAction.ToggleSweep -> settingsRepo.setStateOfSweep(action.value)
is SettingsAction.ToggleSensor -> settingsRepo.setStateOfSensors(action.value)
is SettingsAction.ToggleLightTheme -> settingsRepo.setStateOfLightTheme(action.value)
}
}
private fun handleAction(action: RCAction) {
when (action) {
is RCAction.SetRotatorState -> settingsRepo.setRotatorState(action.value)
is RCAction.SetRotatorAddress -> settingsRepo.setRotatorAddress(action.value)
is RCAction.SetRotatorPort -> settingsRepo.setRotatorPort(action.value)
is RCAction.SetRotatorFormat -> settingsRepo.setRotatorFormat(action.value)
is RCAction.SetFrequencyState -> settingsRepo.setFrequencyState(action.value)
is RCAction.SetFrequencyAddress -> settingsRepo.setFrequencyAddress(action.value)
is RCAction.SetFrequencyPort -> settingsRepo.setFrequencyPort(action.value)
is RCAction.SetFrequencyFormat -> settingsRepo.setFrequencyFormat(action.value)
is RCAction.SetBluetoothRotatorState -> settingsRepo.setBluetoothRotatorState(action.value)
is RCAction.SetBluetoothRotatorAddress -> settingsRepo.setBluetoothRotatorAddress(action.value)
is RCAction.SetBluetoothRotatorFormat -> settingsRepo.setBluetoothRotatorFormat(action.value)
is RCAction.SetBluetoothRotatorName -> settingsRepo.setBluetoothRotatorName(action.value)
is RCAction.SetBluetoothFrequencyState -> settingsRepo.setBluetoothFrequencyState(action.value)
is RCAction.SetBluetoothFrequencyFormat -> settingsRepo.setBluetoothFrequencyFormat(action.value)
is RCAction.SetBluetoothFrequencyAddress -> settingsRepo.setBluetoothFrequencyAddress(action.value)
}
}
private fun handleAction(action: SystemAction) {
when (action) {
is SystemAction.ShowToast -> showToast(action.message)
}
}
private fun handleAction(action: DataSourcesAction) {
when (action) {
is DataSourcesAction.SetUseCustomTle -> settingsRepo.setUseCustomTle(action.value)
is DataSourcesAction.SetUseCustomTransceivers -> settingsRepo.setUseCustomTransceivers(action.value)
is DataSourcesAction.SetTleUrl -> settingsRepo.setTleUrl(action.value)
is DataSourcesAction.SetTransceiversUrl -> settingsRepo.setTransceiversUrl(action.value)
}
}
private fun setGpsPosition() {
if (settingsRepo.setStationPosition()) {
val messageResId = R.string.prefs_loc_success
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = true, messageResId = messageResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
} else {
val errorResId = R.string.prefs_loc_gps_error
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = errorResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
}
private fun setGeoPosition(latitude: Double, longitude: Double) {
if (settingsRepo.setStationPosition(latitude, longitude, 0.0)) {
val messageResId = R.string.prefs_loc_success
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = messageResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
} else {
val errorResId = R.string.prefs_loc_input_error
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = errorResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
}
private fun setQthPosition(locator: String) {
if (settingsRepo.setStationPosition(locator)) {
val messageResId = R.string.prefs_loc_success
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = messageResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
} else {
val errorResId = R.string.prefs_loc_qth_error
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = errorResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
}
private fun dismissPosMessage() {
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = 0
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
private fun updateFromWeb() = viewModelScope.launch {
try {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
_uiState.update { it.copy(dataSettings = newDataSettings) }
databaseRepo.updateFromRemote()
} catch (exception: Exception) {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
_uiState.update { it.copy(dataSettings = newDataSettings) }
println(exception)
}
}
private fun updateTLEFromFile(uri: String) = viewModelScope.launch {
try {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
_uiState.update { it.copy(dataSettings = newDataSettings) }
databaseRepo.updateTLEFromFile(uri)
} catch (exception: Exception) {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
_uiState.update { it.copy(dataSettings = newDataSettings) }
println(exception)
}
}
private fun updateTransceiversFromFile(uri: String) = viewModelScope.launch {
try {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
_uiState.update { it.copy(dataSettings = newDataSettings) }
databaseRepo.updateTransceiversFromFile(uri)
} catch (exception: Exception) {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
_uiState.update { it.copy(dataSettings = newDataSettings) }
println(exception)
}
}
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
if (passes.isEmpty()) return
try {
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
val time = nextPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
} catch (_: NoSuchElementException) {
val lastPass = passes.last()
val time = lastPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
}
}
private fun clearAllData() = viewModelScope.launch { databaseRepo.clearAllData() }
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer {
val container = (this[applicationKey] as MainApplication).container
SettingsViewModel(
container.databaseRepo,
container.satelliteRepo,
container.settingsRepo,
container.provideShowToast()
)
}
}
}
}