diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ace4600..c8ac7976 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,7 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "bundler" + directory: "/" + schedule: + interval: "never" diff --git a/.gitignore b/.gitignore index 805cd155..4937c92c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ fastlane/readme.md /app/release/output-metadata.json /app/release/ /.kotlin/sessions/ +.hermes/ diff --git a/AGENTS.md b/AGENTS.md index 943bd754..b797b073 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po ## Project Overview Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active -satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays -passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite -ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial -data download. +satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to +the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads, +no tracking, no network required after initial data download. -## Architecture +## Architecture & Design **MVI (Model-View-Intent)** with unidirectional data flow: -- `State` data class → exposed via `StateFlow` from ViewModel -- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()` +- `State` data class (named `State`) exposed via `StateFlow` from ViewModel +- `Action` sealed interface (named `Action`) dispatched to ViewModel's `onAction()` - Jetpack Compose UI observes state and recomposes reactively **Clean Architecture layers:** @@ -34,9 +33,11 @@ data download. | `feature:satellites` | Satellite list, filtering, selection | | `feature:settings` | User preferences | -- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other. +**Feature isolation:** +- `feature:*` modules depend only on `core:domain` and `core:presentation`. +- No feature-to-feature dependencies; cross-feature communication goes through core layers. -## Build & Run +## Build & Platform ```shell # Debug build @@ -50,54 +51,55 @@ data download. ``` - **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17 -- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/` +- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/` -## Key Libraries +## Tech Stack - **Compose** (BOM 2026.05.01) + Material3 Adaptive -- **Navigation3** (type-safe, uses `@Serializable` NavKeys) -- **Room** (KSP code generation) for local satellite/TLE storage -- **OkHttp** 5.x for TLE downloads +- **Navigation3**: Type-safe navigation with `@Serializable` nav keys +- **Room** (KSP code generation) for local satellite/orbital storage +- **OkHttp** 5.x for data downloads - **OSMDroid** for map rendering -- **Kotlin Serialization** for navigation args and data parsing +- **Kotlin Serialization** for navigation args and parsing - **Coroutines** + `StateFlow` for async/reactive patterns - -## Conventions - -- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance. -- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface. -- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys. -- **State naming**: `State` data class + `Action` sealed interface per feature. -- **No feature-to-feature deps**: All cross-feature communication goes through core layers. -- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh). +- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh) ## Data Formats & Migration -**TLE vs. OMM/CSV format:** +Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats: -Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility: +- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs +- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support +- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default +- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()` +- Downloads auto-detect format; both produce identical `OrbitalData` objects +- Existing code already supports transparent source transition without feature changes +- Refresh orbital data weekly for accurate pass prediction (orbital decay) -- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which - are running out of space. Celestrak has signaled that TLE format will eventually be phased out. -- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601 - timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format. +## Engineering Heuristics (Lazy = Efficient) -**Current implementation:** -- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly -- TLE data is downloaded from configured sources and stored in Room database -- When downloading satellite data, the app automatically detects format and parses accordingly -- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching +- Treat "lazy" as efficient, not careless: the best code is the code never written. +- First understand the task and trace the real flow end-to-end, then climb this ladder: + 1. Does this need to be built now? (YAGNI) + 2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting. + 3. Does Kotlin/Java stdlib already solve it? + 4. Does the Android/platform API already solve it? + 5. Does an already-installed dependency solve it? + 6. Can this be simpler (including one-liner simple)? + 7. Only then: write the minimum code that works. +- Prefer deletion to addition, boring over clever, and the fewest touched files. +- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested. +- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`. +- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved. +- When two options are similar in size, choose the edge-case-correct one. +- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path. +- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?" -**Migration path:** -As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned -to handle this transition without code changes — existing users can continue using TLE files while new sources -transition to OMM/CSV automatically. +## Bug-Fix Policy -## Code Style - -- Prefer **short, focused functions** — single responsibility, easy to read. -- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer. -- Strict code style — no dead code, no unused imports, consistent formatting. +- Fix root cause, not just the reported symptom. +- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches. +- The smallest correct diff wins only after behavior is understood. ## Roadmap @@ -105,12 +107,9 @@ transition to OMM/CSV automatically. ## Gotchas -- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully. -- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and - will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit. +- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully. - SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass. -- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules. -- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking. +- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules. ## Copilot Working Mode: Code-Only diff --git a/README.md b/README.md index aa34a39e..fb7e88b9 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,10 @@ locally on-device and does not provide services over a network. ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt index 3a8ded66..80913a63 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt @@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) observeNightFilterState() setContent { - MainTheme(isDarkTheme = true) { MainScreen() } + MainTheme(isDarkTheme = true) { NavRoot() } } } diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index ba8261ff..a6aeb293 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -20,6 +20,10 @@ package com.rtbishop.look4sat import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -28,8 +32,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -47,7 +49,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold @@ -95,41 +97,45 @@ import com.rtbishop.look4sat.feature.mutual.MutualViewModel import com.rtbishop.look4sat.feature.passes.PassesDestination import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.radar.WavelogLogScreen -import com.rtbishop.look4sat.feature.status.SatStatusScreen import com.rtbishop.look4sat.feature.roaming.RoamingScreen import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination +import com.rtbishop.look4sat.feature.status.SatStatusDestination @Composable fun NavRoot(deeplink: String? = null) { val rootBackStack = rememberNavBackStack(Screen.Passes) val deeplinkResolver = DeeplinkResolver() LaunchedEffect(deeplink) { - deeplink?.let { - val destination = deeplinkResolver.resolve(it) // rootBackStack.clear() - rootBackStack.add(destination) - } + deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) } } val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } - val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f) - val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it }) + val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) } + // Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style) + val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith + slideOutHorizontally(tween(300)) { -it / 3 } + // Reverse: outgoing slides out to the right, incoming drifts in from the left + val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith + slideOutHorizontally(tween(300)) { it } NavDisplay( modifier = Modifier.fillMaxSize(), backStack = rootBackStack, onBack = navigateBack, - transitionSpec = { slideInTransition }, - popTransitionSpec = { slideOutTransition }, - predictivePopTransitionSpec = { slideOutTransition }, + transitionSpec = { pushTransition }, + popTransitionSpec = { popTransition }, + predictivePopTransitionSpec = { popTransition }, entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry - rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() ), entryProvider = entryProvider { entry { MainScreen() } entry { - Scaffold { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { RadarDestination(navigateUp = navigateBack) - innerPadding.calculateTopPadding() } } } @@ -190,7 +196,12 @@ fun MainScreen() { // newly added pages highlight without touching this call site. val isSelected = currentKey == screen item( - icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, + icon = { + Icon( + painter = painterResource(screen.iconResId), + contentDescription = stringResource(screen.titleResId) + ) + }, label = { Text(stringResource(screen.titleResId)) }, selected = isSelected, onClick = { @@ -275,7 +286,7 @@ fun MainScreen() { CwDecodeScreen() } entry { - SatStatusScreen(container = container) + SatStatusDestination() } entry { WavelogLogScreen(queue = container.wavelogQueue) @@ -361,4 +372,4 @@ fun MainScreen() { } } } -} \ No newline at end of file +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt index c8435c88..957c8fae 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt @@ -45,7 +45,12 @@ interface Look4SatDao { @Query("DELETE FROM entries") suspend fun deleteEntries() - @Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)") + @Query( + """ + SELECT DISTINCT catnum FROM radios WHERE isAlive = 1 + AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes)) + """ + ) suspend fun getIdsWithModes(modes: List): List @Query("SELECT COUNT(*) FROM radios") diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 9236cfbe..e610b5bb 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -97,6 +97,13 @@ class MainContainer(private val context: Context) : IMainContainer { override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context) + override fun providePairedBluetoothDevices(): List> = buildList { + try { + val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) } + } catch (_: SecurityException) {} + } + override fun provideShowToast(): IShowToast = ShowToast(context) override fun provideAudioCapture(): IAudioCapture = AudioCapture() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index 37e06e91..9edc85a3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -35,14 +35,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) { val nowSec = System.currentTimeMillis() / 1000 val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null - val reportsJson = remoteSource.getAmSatReports(hours = REPORTS_HOURS, limit = REPORTS_LIMIT) - ?: return@withContext null - + // 72h = 3 days; API hard cap is limit=500 regardless of what we send. + // 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days. + // Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats. + val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null val names = parseCatalog(catalogJson) val reports = parseReports(reportsJson) - + if (names.isEmpty() && reports.isEmpty()) return@withContext null - + val statuses = buildStatuses(names, reports, nowSec) val reportMap = reports.associate { it.id to toSatReport(it) } SatStatusPage(System.currentTimeMillis(), statuses, reportMap) @@ -53,7 +54,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor return try { val arr = JSONObject(json).getJSONArray("data") (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } - } catch (e: Exception) { + } catch (_: Exception) { emptyList() } } @@ -74,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor reportedTimeUtcSec = parseIsoUtcSec(iso) ) } - } catch (e: Exception) { + } catch (_: Exception) { emptyList() } } @@ -83,60 +84,38 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor private fun parseIsoUtcSec(iso: String): Long { return try { (isoUtcFormat.parse(iso)?.time ?: 0L) / 1000 - } catch (e: Exception) { + } catch (_: Exception) { 0L } } - /** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */ + /** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */ private fun buildStatuses(names: List, reports: List, nowSec: Long): List { val byName = reports.groupBy { it.name } val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - // Anchor the columns on UTC midnight. Slicing a rolling "N x 2 hours ago" - // window into six groups of twelve does not line up with calendar days - // unless the fetch happens just before 24:00 UTC: at 00:00 UTC every one - // of the 72 slots sat under the wrong date label, and at 12:00 UTC half of - // them did, so the column headed "today" showed yesterday's reports. - val todayStartSec = (nowSec / 86400L) * 86400L - // Only show days the API actually covered. Its reports endpoint caps the - // response at 500 rows regardless of the requested limit and fills them - // newest-first, so with ~90 catalogued satellites the oldest days come - // back empty. Rendering a fixed six columns turned "not fetched" into an - // all-gray "nobody reported" column, which is a different claim. - val oldestReportSec = reports.minOfOrNull { it.reportedTimeUtcSec } - val coveredDays = if (oldestReportSec == null) { - 1 - } else { - val oldestDayStart = (oldestReportSec / 86400L) * 86400L - (((todayStartSec - oldestDayStart) / 86400L) + 1).toInt().coerceIn(1, MAX_DAYS) - } - val labels = (0 until coveredDays).map { d -> - utc.timeInMillis = (todayStartSec - d * 86400L) * 1000 + val labels = (0 until 3).map { d -> + utc.timeInMillis = (nowSec - d * 86400L) * 1000 "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" } return names.map { name -> - val reportsForSat = byName[name].orEmpty() - val days = (0 until coveredDays).map { d -> - val dayStart = todayStartSec - d * 86400L - // Newest slot first: the UI picks the first non-gray slot of a day - // as that day's status. - val slots = (0 until 12).map { slotInDay -> - val slotStart = dayStart + (11 - slotInDay) * 7200L - val slotEnd = slotStart + 7200L - val inSlot = reportsForSat.filter { it.reportedTimeUtcSec in slotStart until slotEnd } - if (inSlot.isEmpty()) { - SatSlot(statusColor = NO_REPORT_GRAY, count = 0) - } else { - val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! - SatSlot( - statusColor = statusColorOf(newest.report), - count = inSlot.size, - reportIds = inSlot.map { it.id } - ) - } + val slots = (0 until 36).map { slotIdx -> + val slotStart = nowSec - (slotIdx + 1) * 7200L + val slotEnd = nowSec - slotIdx * 7200L + val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd } + if (inSlot.isEmpty()) { + SatSlot(statusColor = NO_REPORT_GRAY, count = 0) + } else { + val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! + SatSlot( + statusColor = statusColorOf(newest.report), + count = inSlot.size, + reportIds = inSlot.map { it.id } + ) } - SatDay(dateLabel = labels[d], slots = slots) + } + val days = (0 until 3).map { d -> + SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12)) } SatStatus(name = name, days = days) } @@ -169,20 +148,6 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor } companion object { - /** Longest history the status grid will render. */ - private const val MAX_DAYS = 6 - - /** - * The reports endpoint clamps its response to 500 rows and ignores a - * larger limit (measured: limit=1000 still answers with - * `meta.count = 500`), so asking for more is pointless. Rows arrive - * newest-first, which is why the oldest days can be missing entirely. - */ - private const val REPORTS_LIMIT = 500 - - /** Request a full week so the six rendered days have data when available. */ - private const val REPORTS_HOURS = 168 - // AMSAT official status colors (from amsat.org/status) private const val ACTIVE_BLUE = 0xFF648FFF private const val TLM_ORANGE = 0xFFFFB000 diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt index 31aec40e..d0ba8c40 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt @@ -34,11 +34,13 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.util.TimeZone +import kotlin.time.Duration.Companion.milliseconds class SatelliteRepo( private val dispatcher: CoroutineDispatcher, @@ -69,19 +71,23 @@ class SatelliteRepo( override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) override suspend fun initRepository() = withContext(dispatcher) { - settingsRepo.selectedIds.collect { selectedIds -> - _satellites.update { localStorage.getEntriesWithIds(selectedIds) } - val settings = settingsRepo.passesSettings.value - calculatePasses( - time = System.currentTimeMillis(), - hoursAhead = settings.hoursAhead, - minElevation = settings.minElevation, - aosStartMinute = settings.aosStartMinute, - aosEndMinute = settings.aosEndMinute, - invertAosTimeWindow = settings.invertAosTimeWindow, - modes = settings.selectedModes - ) - } + combine( + settingsRepo.selectedIds, + settingsRepo.stationPosition + ) { selectedIds, _ -> selectedIds } + .collect { selectedIds -> + _satellites.update { localStorage.getEntriesWithIds(selectedIds) } + val settings = settingsRepo.passesSettings.value + calculatePasses( + time = System.currentTimeMillis(), + hoursAhead = settings.hoursAhead, + minElevation = settings.minElevation, + aosStartMinute = settings.aosStartMinute, + aosEndMinute = settings.aosEndMinute, + invertAosTimeWindow = settings.invertAosTimeWindow, + modes = settingsRepo.selectedSatModes.value + ) + } } override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos { @@ -186,7 +192,7 @@ class SatelliteRepo( val inRange = if (aosStartMinute <= aosEndMinute) { aosMinute in aosStartMinute..aosEndMinute } else { - aosMinute >= aosStartMinute || aosMinute <= aosEndMinute + aosMinute !in (aosEndMinute + 1)..>(emptyList()) - private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value) private val currentQuery = MutableStateFlow("") - // Resolve type IDs once when types change, then filter items reactively. + // Resolve sat IDs once when modes change, then filter items reactively. // The HashSet gives O(1) catnum lookups instead of O(n) with a List. - private val itemsWithTypes = currentTypes.flatMapLatest { types: List -> - val catnumSet: Set? = if (types.isEmpty()) { + // Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens. + private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List -> + val catnumSet: Set? = if (list.isEmpty()) { null // null = no filtering } else { - val ids = settingsRepo.getSatelliteTypesIds(types) + val ids = localSource.getIdsWithModes(list) if (ids.isEmpty()) null else ids.toHashSet() } currentItems.map { items -> @@ -56,14 +56,18 @@ class SelectionRepo( } private val itemsWithQuery = currentQuery.flatMapLatest { query -> - itemsWithTypes.map { items -> filterByQuery(items, query) } + itemsWithModes.map { items -> + filterByQuery(items, query).sortedWith( + compareByDescending { it.isSelected } + .thenBy { it.name } + .thenBy { it.catnum } + ) + } } - override fun getCurrentTypes() = currentTypes.value + override fun getCurrentModes() = settingsRepo.selectedSatModes.value - override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { - removeAt(0) - } + override fun getModesList() = Sources.satelliteModes override suspend fun getEntriesFlow() = withContext(dispatcher) { val selectedIds = settingsRepo.selectedIds.value.toHashSet() @@ -73,9 +77,8 @@ class SelectionRepo( return@withContext itemsWithQuery } - override suspend fun setTypes(types: List) { - currentTypes.value = types - settingsRepo.setSelectedTypes(types) + override suspend fun setModes(modes: List) { + settingsRepo.setSelectedSatModes(modes) } override suspend fun setQuery(query: String) { diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index a36cb27d..fa11f6dd 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -29,15 +29,18 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings -import com.rtbishop.look4sat.core.domain.source.Sources +import com.rtbishop.look4sat.core.domain.model.Constants + import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo +import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.domain.utility.positionToQth import com.rtbishop.look4sat.core.domain.utility.qthToPosition import com.rtbishop.look4sat.core.domain.utility.round import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import org.json.JSONObject class SettingsRepo( private val context: android.content.Context, @@ -69,9 +72,9 @@ class SettingsRepo( private val keyFrequencyAddress = "frequencyAddress" private val keyFrequencyPort = "frequencyPort" private val keyFrequencyFormat = "frequencyFormat" + private val keyFrequencyOffsetHz = "frequencyOffsetHz" private val keySelectedIds = "selectedIds" - private val keySelectedTypes = "selectedTypes" - private val keySelectedModes = "selectedModes" + private val keySelectedSatModes = "selectedSatModes" private val keyStateOfAutoUpdate = "stateOfAutoUpdate" private val keyStateOfSensors = "stateOfSensors" private val keyStateOfSweep = "stateOfSweep" @@ -100,13 +103,19 @@ class SettingsRepo( private val keyWavelogApiKey = "wavelogApiKey" private val keyWavelogStationId = "wavelogStationId" private val keyWavelogAutoUpload = "wavelogAutoUpload" + private val keyRadarCompassOffset = "radarCompassOffset" + private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" + private val keySatelliteUrls = "satelliteUrls" + private val keyTransceiversUrls = "transceiversUrls" + private val separatorComma = "," + private val separatorUrl = "\n" //region # Satellites selection settings private val _satelliteSelection = MutableStateFlow(getSelectedIds()) - private val _typesSelection = MutableStateFlow(getSelectedTypes()) + private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes()) override val selectedIds: StateFlow> = _satelliteSelection - override val selectedTypes: StateFlow> = _typesSelection + override val selectedSatModes: StateFlow> = _satelliteModeSelection override fun setSelectedIds(ids: List) { val selectionString = ids.joinToString(separatorComma) @@ -114,10 +123,10 @@ class SettingsRepo( _satelliteSelection.value = ids } - override fun setSelectedTypes(types: List) { - val typesString = types.joinToString(separatorComma) - preferences.edit { putString(keySelectedTypes, typesString) } - _typesSelection.value = types + override fun setSelectedSatModes(modes: List) { + val modesString = modes.joinToString(separatorComma) + preferences.edit { putString(keySelectedSatModes, modesString) } + _satelliteModeSelection.value = modes } private fun getSelectedIds(): List { @@ -126,10 +135,10 @@ class SettingsRepo( return selectionString.split(separatorComma).map { it.toInt() } } - private fun getSelectedTypes(): List { - val typesString = preferences.getString(keySelectedTypes, "Amateur") - if (typesString.isNullOrEmpty()) return emptyList() - return typesString.split(separatorComma) + private fun getSelectedSatModes(): List { + val modesString = preferences.getString(keySelectedSatModes, null) + if (modesString.isNullOrEmpty()) return emptyList() + return modesString.split(separatorComma).sorted() } //endregion @@ -144,7 +153,6 @@ class SettingsRepo( putInt(keyFilterAosStartMinute, settings.aosStartMinute) putInt(keyFilterAosEndMinute, settings.aosEndMinute) putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow) - putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma)) _passesSettings.value = settings } @@ -155,16 +163,13 @@ class SettingsRepo( val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59) val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59) val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false) - val selectedModesString = preferences.getString(keySelectedModes, null) - val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList() return PassesSettings( showDeepSpace, hoursAhead, minElevation, aosStartMinute, aosEndMinute, - invertAosTimeWindow, - selectedModes + invertAosTimeWindow ) } //endregion @@ -265,6 +270,7 @@ class SettingsRepo( private val _databaseState = MutableStateFlow(getDatabaseState()) override val databaseState: StateFlow = _databaseState + override fun getSatelliteTypesIds(types: List): List { val idsSet = mutableSetOf() types.forEach { type -> @@ -328,6 +334,10 @@ class SettingsRepo( override val rcSettings: StateFlow = _rcSettings override fun updateRCSettings(settings: RCSettings) { + val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn( + Constants.FREQ_OFFSET_MIN_HZ, + Constants.FREQ_OFFSET_MAX_HZ + ) preferences.edit { putBoolean(keyRotatorState, settings.rotatorState) putString(keyRotatorAddress, settings.rotatorAddress) @@ -337,6 +347,7 @@ class SettingsRepo( putString(keyFrequencyAddress, settings.frequencyAddress) putString(keyFrequencyPort, settings.frequencyPort) putString(keyFrequencyFormat, settings.frequencyFormat) + putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz) putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState) putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat) putString(keyBluetoothRotatorName, settings.bluetoothRotatorName) @@ -345,7 +356,7 @@ class SettingsRepo( putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat) putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress) } - _rcSettings.value = settings + _rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz) } private fun getRCSettings(): RCSettings = RCSettings( @@ -357,6 +368,8 @@ class SettingsRepo( frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1", frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532", frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ", + frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L) + .coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ), bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false), bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL", bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default", @@ -393,6 +406,9 @@ class SettingsRepo( putString(keyWavelogApiKey, new.wavelogApiKey) putString(keyWavelogStationId, new.wavelogStationId) putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload) + putFloat(keyRadarCompassOffset, new.radarCompassOffset) + putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev) + } new } @@ -416,7 +432,9 @@ class SettingsRepo( wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "", wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "", wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "", - wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false) + wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false), + radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f), + radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f) ) //endregion @@ -448,6 +466,7 @@ class SettingsRepo( transceiversUrl = txUrl ) } + //endregion //region # Radio control settings @@ -487,5 +506,27 @@ class SettingsRepo( baudRate = preferences.getInt(keyRadioBaudRate, 4800), splitMode = preferences.getBoolean(keyRadioSplitMode, false) ) - //endregion + + private val keySatelliteOffsets = "satelliteOffsets" + + override fun getSatelliteOffset(catnum: Int): String { + val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}" + return try { + JSONObject(json).optString(catnum.toString(), "") + } catch (_: Exception) { + "" + } + } + + override fun setSatelliteOffset(catnum: Int, offset: String) { + val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}" + val updated = try { + val obj = JSONObject(json) + if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset) + obj.toString() + } catch (_: Exception) { + """{"$catnum": "$offset"}""" + } + preferences.edit { putString(keySatelliteOffsets, updated) } + } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index a24191c0..e2fa0af6 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import java.io.ByteArrayInputStream import java.io.InputStream class RemoteSource( @@ -67,10 +66,14 @@ class RemoteSource( override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { try { val networkRequest = Request.Builder().url(url).build() - httpClient.newCall(networkRequest).execute().use { response -> - if (!response.isSuccessful) return@withContext null - ByteArrayInputStream(response.body.bytes()) + val response = httpClient.newCall(networkRequest).execute() + if (!response.isSuccessful) { + response.close() + return@withContext null } + // Return the body stream directly as the caller is responsible for closing it + // That returns the connection to OkHttp's pool + response.body.byteStream().buffered() } catch (exception: CancellationException) { throw exception } catch (exception: Exception) { @@ -93,6 +96,7 @@ class RemoteSource( throw exception } catch (exception: Exception) { println("RemoteSource amsat catalog exception: $exception") + null } } @@ -111,6 +115,7 @@ class RemoteSource( throw exception } catch (exception: Exception) { println("RemoteSource amsat reports exception: $exception") + null } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 5ea8d969..3b76b6ca 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -172,10 +172,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override val selectedIds: StateFlow> = MutableStateFlow(emptyList()) - override val selectedTypes: StateFlow> = MutableStateFlow(emptyList()) + override val selectedSatModes: StateFlow> = MutableStateFlow(emptyList()) override val passesSettings: StateFlow = MutableStateFlow( - PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList()) + PassesSettings(hoursAhead = 24, minElevation = 0.0) ) override val stationPosition: StateFlow = MutableStateFlow(GeoPos(0.0, 0.0)) @@ -183,7 +183,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override val databaseState: MutableStateFlow = MutableStateFlow(DatabaseState(0, 0, 0L)) override val rcSettings: StateFlow = MutableStateFlow( - RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "") + RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "") ) override val otherSettings: StateFlow = MutableStateFlow( @@ -200,7 +200,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override fun setSelectedIds(ids: List) = Unit - override fun setSelectedTypes(types: List) = Unit + override fun setSelectedSatModes(modes: List) = Unit override fun setPassesSettings(settings: PassesSettings) = Unit @@ -229,6 +229,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou } override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit + + override fun getSatelliteOffset(catnum: Int): String = "" + + override fun setSatelliteOffset(catnum: Int, offset: String) = Unit } private fun defaultDataSourcesSettings(): DataSourcesSettings { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt new file mode 100644 index 00000000..481c7e8b --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt @@ -0,0 +1,181 @@ +/* + * 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 . + */ +package com.rtbishop.look4sat.core.data.repository + +import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings +import com.rtbishop.look4sat.core.domain.model.DatabaseState +import com.rtbishop.look4sat.core.domain.model.OtherSettings +import com.rtbishop.look4sat.core.domain.model.PassesSettings +import com.rtbishop.look4sat.core.domain.model.RCSettings +import com.rtbishop.look4sat.core.domain.model.RadioControlSettings +import com.rtbishop.look4sat.core.domain.model.SatItem +import com.rtbishop.look4sat.core.domain.model.SatRadio +import com.rtbishop.look4sat.core.domain.predict.GeoPos +import com.rtbishop.look4sat.core.domain.predict.OrbitalObject +import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo +import com.rtbishop.look4sat.core.domain.source.ILocalSource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SelectionRepoTest { + + private val dispatcher = StandardTestDispatcher() + + @Test + fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) { + val localSource = FakeLocalSource( + entries = listOf( + SatItem(25544, "ISS (ZARYA)", false), + SatItem(40967, "TIANGONG", false) + ) + ) + val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE")) + val repository = SelectionRepo(dispatcher, localSource, settingsRepo) + + val flow = repository.getEntriesFlow() + repository.setModes(listOf("REMOVED_MODE")) + + val items = flow.first() + + assertEquals(listOf(25544, 40967), items.map { it.catnum }) + assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes()) + } + + @Test + fun `selected satellites are shown first`() = runTest(dispatcher) { + val localSource = FakeLocalSource( + entries = listOf( + SatItem(44444, "Zeta", false), + SatItem(25544, "Alpha", false), + SatItem(40967, "Beta", false) + ) + ) + val settingsRepo = FakeSettingsRepo(selectedModes = emptyList()) + val repository = SelectionRepo(dispatcher, localSource, settingsRepo) + + val flow = repository.getEntriesFlow() + repository.setSelection(listOf(40967), true) + + val items = flow.first() + + assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum }) + assertEquals(listOf(true, false, false), items.map { it.isSelected }) + } + + private class FakeLocalSource( + private val entries: List + ) : ILocalSource { + override suspend fun getEntriesTotal(): Int = entries.size + + override suspend fun getEntriesList(): List = entries + + override suspend fun getEntriesWithIds(ids: List): List = emptyList() + + override suspend fun insertEntries(entries: List) = Unit + + override suspend fun deleteEntries() = Unit + + override suspend fun getIdsWithModes(modes: List): List = emptyList() + + override suspend fun getRadiosTotal(): Int = 0 + + override suspend fun getRadiosWithId(id: Int): List = emptyList() + + override suspend fun insertRadios(radios: List) = Unit + + override suspend fun deleteRadios() = Unit + } + + private class FakeSettingsRepo( + selectedModes: List + ) : ISettingsRepo { + + override val appVersionName: String = "test" + + override val selectedIds: StateFlow> = MutableStateFlow(emptyList()) + + override val selectedSatModes: MutableStateFlow> = MutableStateFlow(selectedModes) + + override val passesSettings: StateFlow = MutableStateFlow( + PassesSettings(hoursAhead = 24, minElevation = 0.0) + ) + + override val stationPosition: StateFlow = MutableStateFlow(GeoPos(0.0, 0.0)) + + override val databaseState: MutableStateFlow = MutableStateFlow(DatabaseState(0, 0, 0L)) + + override val rcSettings: StateFlow = MutableStateFlow( + RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "") + ) + + override val otherSettings: StateFlow = MutableStateFlow( + OtherSettings(false, false, false, false, false, false, false, false) + ) + + override val dataSourcesSettings: StateFlow = MutableStateFlow( + DataSourcesSettings(false, false, "", "") + ) + + override val radioControlSettings: StateFlow = MutableStateFlow( + RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) + ) + + override fun setSelectedIds(ids: List) = Unit + + override fun setSelectedSatModes(modes: List) { + selectedSatModes.value = modes + } + + override fun setPassesSettings(settings: PassesSettings) = Unit + + override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true + + override suspend fun setStationPosition(): Boolean = true + + override fun setStationPosition(locator: String): Boolean = true + + override fun getSatelliteTypesIds(types: List): List = emptyList() + + override fun setSatelliteTypeIds(type: String, ids: List) = Unit + + override fun updateDatabaseState(state: DatabaseState) { + databaseState.value = state + } + + override fun updateRCSettings(settings: RCSettings) = Unit + + override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit + + override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit + + override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit + + override fun getSatelliteOffset(catnum: Int): String = "" + + override fun setSatelliteOffset(catnum: Int, offset: String) = Unit + } +} + + diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt new file mode 100644 index 00000000..679c1de5 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt @@ -0,0 +1,23 @@ +/* + * 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 . + */ +package com.rtbishop.look4sat.core.domain.model + +object Constants { + const val FREQ_OFFSET_MIN_HZ = -50_000L + const val FREQ_OFFSET_MAX_HZ = 50_000L +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt index 22c9fae8..d3554047 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt @@ -23,10 +23,10 @@ data class SatDay( val slots: List // 12 槽(00-02 ... 22-24) ) -/** One satellite, 6 days of state */ +/** One satellite, 5 days of state */ data class SatStatus( val name: String, // "AO-123_[FM]" - val days: List // 6 天(新→旧) + val days: List // 5 天(新→旧) ) /** Overall page parse result */ diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index 425e3352..3dcc4575 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -29,8 +29,7 @@ data class PassesSettings( val minElevation: Double, val aosStartMinute: Int = 0, val aosEndMinute: Int = 23 * 60 + 59, - val invertAosTimeWindow: Boolean = false, - val selectedModes: List + val invertAosTimeWindow: Boolean = false ) data class RCSettings( @@ -42,6 +41,7 @@ data class RCSettings( val frequencyAddress: String, val frequencyPort: String, val frequencyFormat: String, + val frequencyOffsetHz: Long = 0L, val bluetoothRotatorState: Boolean, val bluetoothRotatorFormat: String, val bluetoothRotatorName: String, @@ -73,7 +73,10 @@ data class OtherSettings( val wavelogUrl: String = "", val wavelogApiKey: String = "", val wavelogStationId: String = "", - val wavelogAutoUpload: Boolean = false + val wavelogAutoUpload: Boolean = false, + // Upstream radar compass offset (merged from rt-bishop) + val radarCompassOffset: Float = 0f, + val radarCompassOffsetElev: Float = 0f ) data class DataSourcesSettings( diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt index 6a3cdbd8..45b4db01 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt @@ -424,8 +424,12 @@ object CelestialComputer { } if (sunrise == 0.0) sunrise = daynum - // Phase 4: fast-forward through the day until sun drops back below threshold - daynum = sunrise + // Phase 4: fast-forward through the day until sun drops back below threshold. + // Start from just after sunrise (small offset) so the sun is clearly above + // the threshold. This prevents a bug where Phase 3 converges to a point + // slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge + // to the same time as sunrise, producing identical sunrise/sunset times. + daynum = sunrise + 0.001 sunPos = getSunPosition(observer, daynumToMillis(daynum)) guard = 0 while (sunPos.elevation > -threshold && guard++ < 500) { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index 2d165d45..e4504b37 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -35,6 +35,7 @@ interface IMainContainer { val mutualPassData: StateFlow fun setMutualPassData(data: MutualPassData) fun provideAddToCalendar(): IAddToCalendar + fun providePairedBluetoothDevices(): List> fun provideShowToast(): IShowToast fun provideBluetoothReporter(): IReporter fun provideNetworkReporter(): IReporter diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt index 33776225..6111ad9c 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt @@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem import kotlinx.coroutines.flow.Flow interface ISelectionRepo { - fun getCurrentTypes(): List - fun getTypesList(): List + fun getCurrentModes(): List + fun getModesList(): List suspend fun getEntriesFlow(): Flow> - suspend fun setTypes(types: List) + suspend fun setModes(modes: List) suspend fun setQuery(query: String) suspend fun setSelection(selectAll: Boolean) suspend fun setSelection(ids: List, isTicked: Boolean) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 69120461..e832f08e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -32,9 +32,9 @@ interface ISettingsRepo { //region # Satellites selection settings val selectedIds: StateFlow> - val selectedTypes: StateFlow> + val selectedSatModes: StateFlow> fun setSelectedIds(ids: List) - fun setSelectedTypes(types: List) + fun setSelectedSatModes(modes: List) //endregion //region # Passes filter settings @@ -78,4 +78,9 @@ interface ISettingsRepo { val radioControlSettings: StateFlow fun updateRadioControlSettings(settings: RadioControlSettings) //endregion + + //region # Per-satellite calculator offset settings + fun getSatelliteOffset(catnum: Int): String + fun setSatelliteOffset(catnum: Int, offset: String) + //endregion } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt index 601db98f..7a13331b 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt @@ -52,6 +52,15 @@ object Sources { "R4UAB" to "https://r4uab.ru/satonline.txt", "Other" to "" // key for sats filter ) + val satelliteModes = listOf( + "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", + "BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR", + "DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6", + "FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK", + "GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", + "MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31", + "QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT" + ) val transceiversDataUrls = mapOf( "SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active" ) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt index 6d17f1ef..bda2126b 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt @@ -96,7 +96,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) { val line1 = tle[1] val line2 = tle[2] OrbitalData( - name = tle[0].trim(), + name = tle[0].trim().removePrefix("0 "), epoch = line1.substring(18, 32).toDouble(), meanmo = line2.substring(52, 63).toDouble(), eccn = line2.substring(26, 33).toDouble() / 1e7, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt index 51c59129..e05b3d19 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -114,11 +114,30 @@ object DopplerFrequencyCalculator { val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) .joinToString(separator = " ") .lowercase(Locale.ENGLISH) - val hasLinearName = info.contains("linear") + val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin") val hasTransponderName = info.contains("transponder") || info.contains("transp") || info.contains("xponder") || info.contains("xpdr") val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) } - return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) + return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) || + (hasLinearName && hasLinearMode) + } + + /** + * Removes duplicate transponder entries that describe the same physical + * transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A + * as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both + * "CW Transponder" and "SSB Transponder"). + * + * Entries sharing the same uplink/downlink frequency range are considered + * the same transponder. The non-CW entry is preferred because its invert + * flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false). + */ + fun deduplicateTransponders(radios: List): List { + return radios.groupBy { radio -> + listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh) + }.values.map { group -> + group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first() + } } } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt index 410e8bf7..30619675 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt @@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double { return kotlin.math.round(this * multiplier) / multiplier } +fun String.aprsPasscode(): Int { + val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID + var hash = 0x73E2 + var i = 0 + while (i < callsign.length) { + hash = hash xor (callsign[i].code shl 8) + i++ + if (i < callsign.length) { + hash = hash xor callsign[i].code + i++ + } + } + return hash and 0x7FFF +} + //fun String.getHash(type: String = "SHA-256"): String { // val hexChars = "0123456789ABCDEF" // val bytes = MessageDigest.getInstance(type).digest(this.toByteArray()) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt index 93a6f32f..0a7f8966 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -18,6 +18,7 @@ package com.rtbishop.look4sat.core.domain import com.rtbishop.look4sat.core.domain.utility.DataParser +import com.rtbishop.look4sat.core.domain.utility.aprsPasscode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -281,4 +282,10 @@ class DataParserTest { // Matches the CSV test data epoch: 2021-11-16 → day 320 assert(dataParser.getDayOfYear(2021, 11, 16) == 320) } + + @Test + fun `check APRS passcode calculation`() { + assert("M7LNB".aprsPasscode() == 12443) + assert("N0CALL".aprsPasscode() == 13023) + } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt index 709e54d1..dd832672 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt @@ -3,12 +3,17 @@ package com.rtbishop.look4sat.core.domain import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class DopplerFrequencyCalculatorTest { private fun linearTransponder( + uuid: String = "linear", upLow: Long = 145_000_000L, upHigh: Long = 145_500_000L, downLow: Long = 435_000_000L, @@ -18,7 +23,7 @@ class DopplerFrequencyCalculatorTest { downlinkMode: String? = "USB", uplinkMode: String? = "LSB" ) = SatRadio( - uuid = "linear", info = info, isAlive = true, + uuid = uuid, info = info, isAlive = true, downlinkLow = downLow, downlinkHigh = downHigh, downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345 @@ -68,19 +73,99 @@ class DopplerFrequencyCalculatorTest { assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry)) } + @Test + fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() { + // AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word + val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry)) + val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry)) + val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry)) + } + + @Test + fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() { + // AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder" + val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry)) + } + + @Test + fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() { + // "Downlink" contains "lin" but is not a linear-transponder name + val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM") + assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry)) + } + @Test fun isNamedLinearTransponder_returnsFalseForFmRepeater() { assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder())) } + @Test + fun deduplicateTransponders_mergesSameFrequencyRange() { + // AO-7's Mode A: same range, SSB and CW entries + val ssb = linearTransponder( + uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB", + downlinkMode = "USB", uplinkMode = "USB" + ) + val cw = linearTransponder( + uuid = "cw-uuid", info = "Mode V/A (A) Lin CW", + downlinkMode = "CW", uplinkMode = "CW" + ) + val modeB = linearTransponder( + uuid = "modeb-uuid", info = "Mode U/V (B) Lin", + upLow = 432_125_000L, upHigh = 432_175_000L, + downLow = 145_925_000L, downHigh = 145_975_000L, + downlinkMode = "USB", uplinkMode = "LSB" + ) + val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB)) + assertEquals(2, result.size) + // SSB entry should be preferred over CW (same range) + assertEquals("ssb-uuid", result[0].uuid) + assertEquals("modeb-uuid", result[1].uuid) + } + + @Test + fun deduplicateTransponders_prefersNonCwEntry() { + // JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct) + val cw = linearTransponder( + uuid = "cw-uuid", info = "U/V CW Transponder", + downlinkMode = "CW", uplinkMode = "CW", + upLow = 435_100_000L, upHigh = 435_120_000L, + downLow = 145_855_000L, downHigh = 145_875_000L + ) + val ssb = linearTransponder( + uuid = "ssb-uuid", info = "U/V SSB Transponder", + downlinkMode = "USB", uplinkMode = "LSB", + upLow = 435_100_000L, upHigh = 435_120_000L, + downLow = 145_855_000L, downHigh = 145_875_000L, + inverted = true + ) + val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb)) + assertEquals(1, result.size) + assertEquals("ssb-uuid", result[0].uuid) + // Verify the correct invert flag is preserved + assertTrue(result[0].isInverted) + } + + @Test + fun deduplicateTransponders_preservesUniqueEntries() { + val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L, + downLow = 435_000_000L, downHigh = 435_500_000L) + val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L, + downLow = 145_000_000L, downHigh = 145_500_000L) + val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2)) + assertEquals(2, result.size) + } + @Test fun computeUplinkFromDownlink_linear_noDoppler() { val xpdr = linearTransponder() val orbitalPos = pos(0.0) val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) assertNotNull(uplink) - assertTrue(uplink!! > 0) - // With zero Doppler, result equals mapDownlinkToUplink output assertEquals(145_200_000L, uplink) } @@ -95,24 +180,23 @@ class DopplerFrequencyCalculatorTest { @Test fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() { - // Satellite receding (positive range rate) → ground must transmit higher freq to compensate + // Satellite receding (positive range rate) → ground must transmit higher freq to compensate. val xpdr = linearTransponder() - val orbitalPos = pos(7.0) // ~7 km/s receding + val orbitalPos = pos(7.0) val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) assertNotNull(uplink) - // Uplink freq should be Doppler shifted UP (compensating for receding) assertTrue(uplink!! > 145_200_000L) } @Test - fun computeUplinkFromDownlink_fm_transponder_returnsNull() { + fun computeUplinkFromDownlink_fmTransponder_returnsNull() { val orbitalPos = pos() val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos) assertNull(result) } @Test - fun computeDownlinkFromUplink_fm_transponder_returnsNull() { + fun computeDownlinkFromUplink_fmTransponder_returnsNull() { val orbitalPos = pos() val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos) assertNull(result) @@ -171,13 +255,11 @@ class DopplerFrequencyCalculatorTest { val orbitalPos = pos(0.0) val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) assertNotNull(uplink) - // Inverted: offset from high end → maps to high end of uplink assertEquals(145_300_000L, uplink) } @Test fun computeUplinkFromDownlink_roundTrip() { - // downlink → uplink → downlink should round-trip val xpdr = linearTransponder() val orbitalPos = pos(3.5) val originalDownlink = 435_250_000L @@ -185,7 +267,6 @@ class DopplerFrequencyCalculatorTest { assertNotNull(uplink) val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos) assertNotNull(roundTripDownlink) - // Doppler round-trip: small residual due to freq-dependent Doppler val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink) assertTrue("Round-trip error too large: $error", error < 10000) } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt index aeb944ea..d2fc7f85 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt @@ -1,17 +1,28 @@ +/* + * 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 . + */ package com.rtbishop.look4sat.core.domain.predict +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.time.Instant +import kotlin.math.abs -/** - * The Moon's Greenwich Hour Angle must be a reduced angle: callers turn it into a - * sub-lunar longitude with `if (gha <= 180) -gha else 360 - gha`, which only - * yields a valid longitude when gha is within 0..360. - * - * Regression: the GMST polynomial feeding the hour angle is never reduced (about - * 3.5e6 degrees at present), so the old `if (gha < 0) gha += 360` guard never - * fired and every sub-lunar longitude landed millions of degrees off the map. - */ class CelestialComputerTest { private val observer = GeoPos(22.314066, 108.706575) @@ -40,16 +51,55 @@ class CelestialComputerTest { assertTrue("expected a meaningful sweep, got $samples samples", samples > 1000) } + private data class RiseSetCase( + val name: String, + val observer: GeoPos, + val startIso: String + ) + @Test - fun `moon hour angle advances westward over an hour`() { - // The Moon's hour angle grows by roughly 14.5 degrees per hour, so an - // hour apart the two readings must differ but stay reduced. - val base = 1786060800_000L - val first = CelestialComputer.getMoonPosition(observer, base).gha - val later = CelestialComputer.getMoonPosition(observer, base + 3_600_000L).gha - assertTrue("first=$first", first >= 0.0 && first < 360.0) - assertTrue("later=$later", later >= 0.0 && later < 360.0) - val delta = ((later - first) % 360.0 + 360.0) % 360.0 - assertTrue("hourly advance was $delta degrees", delta in 10.0..20.0) + fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() { + val cases = listOf( + RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"), + RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"), + RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"), + RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"), + RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"), + RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z") + ) + + cases.forEach { testCase -> + val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis()) + val daylightDuration = result.setTimeMillis - result.riseTimeMillis + + assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L) + assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L) + assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis) + assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS) + assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS) + + val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation + val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation + assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02) + assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02) + } + } + + @Test + fun `findSunRiseSet does not return the same instant for equinox regression cases`() { + listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso -> + val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis()) + val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis) + + assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS) + } + } + + private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli() + + private companion object { + private const val SUNRISE_SET_THRESHOLD = -0.8333 + private const val HOUR_MILLIS = 60L * 60L * 1000L + private const val DAY_MILLIS = 24L * HOUR_MILLIS } } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index 8f250300..d6d49351 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -27,13 +27,18 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -71,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit @@ -143,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() } val sdfTime = remember(isUtc) { - SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone } + SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone } } ElevatedCard( modifier = modifier @@ -209,6 +216,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc } } +private fun displayLocale(): Locale { + val locale = Locale.getDefault() + return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH +} + @Composable fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) { ElevatedButton( @@ -224,8 +236,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) } @Composable -fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) { - ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) { +fun IconCard( + action: () -> Unit, resId: Int, modifier: Modifier = Modifier, + enabled: Boolean = true, containerColor: Color = Color.Unspecified +) { + val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors() + else CardDefaults.elevatedCardColors(containerColor = containerColor) + ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) } @@ -280,6 +297,35 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass( altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f ) +@Composable +fun InfoDialog( + title: String, + onDismiss: () -> Unit, + onAccept: () -> Unit, + content: @Composable () -> Unit +) { + DialogShell(onDismissRequest = onDismiss) { padding -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, top = padding, end = padding) + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) + } + content() + } +} + @Composable fun SharedDialog( title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit @@ -329,6 +375,56 @@ fun SharedDialog( } } +@Composable +fun ConfirmDialog( + title: String, + onCancel: () -> Unit, + onAccept: () -> Unit, + content: @Composable () -> Unit +) { + DialogShell(onDismissRequest = onCancel) { padding -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, top = padding, end = padding) + ) { + CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel)) + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = padding) + ) + CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) + } + content() + } +} + +@Composable +fun WhatsNewDialog(onDismiss: () -> Unit) { + InfoDialog( + title = stringResource(R.string.pass_whatsnew_title), + onDismiss = onDismiss, + onAccept = onDismiss + ) { + Text( + text = stringResource(R.string.pass_whatsnew_message), + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = LocalSpacing.current.large) + ) + Spacer(modifier = Modifier.height(0.dp)) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DialogShell( @@ -337,6 +433,9 @@ private fun DialogShell( ) { val padding = LocalSpacing.current.large val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() } + val maxSheetHeight = containerHeight - statusBarHeight val stopSheetFling = remember { object : NestedScrollConnection { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { @@ -354,7 +453,7 @@ private fun DialogShell( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, - shape = MaterialTheme.shapes.medium, + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), scrimColor = Color.Black.copy(alpha = 0.64f) ) { Column( @@ -362,6 +461,7 @@ private fun DialogShell( verticalArrangement = Arrangement.spacedBy(padding), modifier = Modifier .fillMaxWidth() + .heightIn(max = maxSheetHeight) .nestedScroll(stopSheetFling) ) { content(padding) diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index 7414683f..983b407a 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey { @Serializable - data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites") + data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat, "Satellites") @Serializable data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes") @Serializable - data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar") + data object Radar : Screen(R.drawable.ic_satellite, R.string.nav_radar, "Radar") @Serializable data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map") @@ -48,7 +48,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog") @Serializable - data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT") + data object AmSat : Screen(R.drawable.ic_satellite, R.string.nav_amsat, "AMSAT") @Serializable data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings") diff --git a/core/presentation/src/main/res/drawable/ic_add.xml b/core/presentation/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..1b016dcf --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/drawable/ic_like.xml b/core/presentation/src/main/res/drawable/ic_like.xml new file mode 100644 index 00000000..14595ca4 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_like.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/drawable/ic_pound.xml b/core/presentation/src/main/res/drawable/ic_pound.xml deleted file mode 100644 index ae8a8732..00000000 --- a/core/presentation/src/main/res/drawable/ic_pound.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/presentation/src/main/res/drawable/ic_radar.xml b/core/presentation/src/main/res/drawable/ic_radar.xml deleted file mode 100644 index fbfcebb2..00000000 --- a/core/presentation/src/main/res/drawable/ic_radar.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/presentation/src/main/res/drawable/ic_refresh.xml b/core/presentation/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..9e52eb73 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,10 @@ + + + + diff --git a/core/presentation/src/main/res/drawable/ic_satellite.xml b/core/presentation/src/main/res/drawable/ic_satellite.xml new file mode 100644 index 00000000..5fbe7163 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_satellite.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/core/presentation/src/main/res/drawable/ic_satellites.xml b/core/presentation/src/main/res/drawable/ic_sputnik.xml similarity index 100% rename from core/presentation/src/main/res/drawable/ic_satellites.xml rename to core/presentation/src/main/res/drawable/ic_sputnik.xml diff --git a/core/presentation/src/main/res/drawable/ic_tags.xml b/core/presentation/src/main/res/drawable/ic_tags.xml deleted file mode 100644 index ad7640ae..00000000 --- a/core/presentation/src/main/res/drawable/ic_tags.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index 1d9bd834..095ed335 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -13,12 +13,16 @@ Ajustes - Tipo: %s - Seleccionar tipo de satélite + Modos: %s + Seleccionar modos Id - Nombre Vaciar Vaciar todo Seleccionar todo + Seleccionados + Disponibles + Seleccionados (%1$d) + Disponibles (%1$d) Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada Alarma\! @@ -26,7 +30,7 @@ Esta aplicación incluye más de 9000 satélites. No tiene sentido rastrearlos todos a la vez. \n\nIntenta siempre limitar la lista a los que te - interesen mediante la búsqueda y el selector de tipos. + interesen mediante la búsqueda y el selector de modos. Filtrar pases @@ -35,7 +39,7 @@ Ventana AOS Invertir ventana AOS DeepSpace (período >225min) - Tipo de modulación + Seleccionar modos Elevación: %.1f° Altitud: %.0f km @@ -96,11 +100,11 @@ QTH Ubicación QTH no válida Ubicación actualizada con éxito - Ajustar ubicaicón de la estación - Latitud de tu estación terrestre - Longitud de tu estación terrestre - Ajustes ubicación QTH - Ajustar posición de la estación con tu ubicación + Ubicación de la estación + Latitud de estación + Longitud de estación + Localizador QTH + Posición por localizador Datos satelitales Satélites: %s @@ -110,39 +114,52 @@ Vaciar Datos eliminados con éxito Actualización completada con éxito + No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido. + No se importaron transceptores. Usa un SatNOGS (.json) válido. - Custom data sources - Custom TLE URL - Custom transceivers URL + Fuentes personalizadas + URL TLE + URL transceptores + URL (HTTPS) - Data output - Network + Salida de datos + Red Bluetooth + CAT - Salida de datos de red - Enable rotation output + Control de radio + Control CAT + Modelo de radio + Dirección BT TX + Dirección BT RX + Nombre radio TX + Nombre radio RX + Activar CAT + + Salida de red + Activar salida de rotación IP:Puerto Formato de datos - Enable frequency output + Activar salida de frecuencia IP:Puerto Formato de datos - Salida por Bluetooth - Enable rotation output - Id dispositivo - Formato de datos - Enable frequency output - Id dispositivo - Formato de datos + Salida Bluetooth + Activar salida de rotación + ID dispositivo + Formato + Activar salida de frecuencia + ID dispositivo + Formato Revisa los permisos de Bluetooth Revisa los permisos de red - Otros ajustes - Mostrar tiempos de pase en UTC - Habilitar auto actualización de datos - Habilitar animaciones radar - Usar sensores para rotar vista de radar - Activar filtro nocturno rojo + Otros + Hora UTC + Actualización automática + Animación radar + Sensores + Filtro nocturno Resaltado de elevación Baja < %1$d° diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index 17031bf9..4c73ec04 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -13,19 +13,23 @@ Настройки - Тип: %s - Выберите тип спутника + Режимы: %s + Выберите режимы Id - Название Очистить Очистить Выбрать + Выбранные + Доступные + Выбранные (%1$d) + Доступные (%1$d) Убедитесь, что ваш поисковый запрос верен и база данных обновлена Внимание\! В этом приложении перечислено более 9000 спутников. Отслеживать их все одновременно бессмысленно. \n\nВсегда старайтесь сузить список до тех, - которые вас интересуют, используя поиск и селектор типов. + которые вас интересуют, используя поиск и выбор режимов. Фильтровать пролеты @@ -34,7 +38,7 @@ Окно AOS Инвертировать окно AOS DeepSpace (период >225мин) - Выберите тип модуляции + Выберите режимы Элевация: %.1f° Высота: %d км @@ -95,11 +99,11 @@ QTH Неправильный локатор QTH Позиция успешно обновлена - Настройки местоположения - Введите широту наземной станции - Введите долготу наземной станции - Настройки локатора QTH - Введите позицию через локатор + Положение станции + Широта станции + Долгота станции + Локатор QTH + Позиция по локатору Данные спутников Спутников: %s @@ -109,16 +113,30 @@ Очистить Очистка прошла успешно Обновление прошло успешно + Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv). + Трансиверы не импортированы. Нужен SatNOGS (.json). - Свои источники данных - Custom TLE URL - Custom Transceivers URL + Свои источники + URL TLE + URL трансиверов + URL (HTTPS) - Вывод данных + Вывод Сеть Bluetooth + CAT - Вывод сетевых данных + Управление радио + + CAT управление + Модель радио + BT адрес TX + BT адрес RX + Имя радио TX + Имя радио RX + Включить CAT + + Сетевой вывод Включить вывод ротатора IP:Порт Формат @@ -126,22 +144,22 @@ IP:Порт Формат - Вывод данных Bluetooth + Вывод Bluetooth Включить вывод ротатора - Id устройства + ID устройства Формат Включить вывод частоты - Id устройства + ID устройства Формат Нет разрешения использовать bluetooth Нет разрешения использовать сеть - Другие настройки - Показывать время по UTC - Обновлять данные автоматически - Показывать анимацию радара - Использовать сенсоры устройства - Включить красный ночной фильтр + Прочее + Время в UTC + Автообновление данных + Анимация радара + Сенсоры + Ночной фильтр Подсветка высоты Низко < %1$d° diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index 44d1433b..32af6348 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -13,19 +13,23 @@ පසුතල - වර්ගය: %s - චන්ද්‍රිකා ආකාරය තෝරන්න + මාදිලි: %s + මාදිලි තෝරන්න Id - නම පිරිසිදු ක° සියල්ල පිරිසිදු ක° සියල්ල තෝරන්න + තෝරාගත් + තිබෙන + තෝරාගත් (%1$d) + තිබෙන (%1$d) ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න අවවාදයයි\! මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්‍රිකා 9000 කට වඩා තිබේ. ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්. - \n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන + \n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න. @@ -35,7 +39,7 @@ AOS කවුළුව AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න DeepSpace (කාලය >225min) - මූර්ජන ආකාරය තෝරන්න + මූර්ජන තෝරන්න උත්තෝලනය: %.1f° උන්නතාශය: %d km @@ -96,11 +100,11 @@ QTH QTH locator වලංගු නොවේ පිහිටීම යාවත්කාලීනය සාර්ථකයි - පොළෙහි පිහිටීම් පසුතල - ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න - ඔබගේ පොළෙහි දේශාංශය සකසන්න - QTH locator පසුතල - ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න + පොළ පිහිටීම + පොළ අක්‍ෂාංශය + පොළ දේශාංශය + QTH locator + locator මඟින් පොළ පිහිටීම සකසන්න චන්ද්‍රිකා දත්ත චන්ද්‍රිකා: %s @@ -110,39 +114,52 @@ පිරිසිදු ක° දත්ත ඉවත් කිරීම සාර්ථකයි යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි + චන්ද්‍රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න. + සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න. - Custom data sources - Custom TLE URL - Custom transceivers URL + අභිරුචි මූලාශ්‍ර + TLE URL + Transceivers URL + URL (HTTPS) - Data output - Network + දත්ත ප්‍රතිදානය + ජාලය Bluetooth + CAT + + රේඩියෝ පාලනය + CAT පාලනය + රේඩියෝ මාදිලිය + TX BT ලිපිනය + RX BT ලිපිනය + TX රේඩියෝ නම + RX රේඩියෝ නම + CAT සබල කරන්න ජාල ප්‍රතිදානය - Enable rotation output + Rotation ප්‍රතිදානය සබල කරන්න IP:Port - දත්ත අකාරය - Enable frequency output + දත්ත ආකෘතිය + Frequency ප්‍රතිදානය සබල කරන්න IP:Port - දත්ත අකාරය + දත්ත ආකෘතිය - Bluetooth දත්ත ප්‍රතිදානය - Enable rotation output - උපාංග id - දත්ත අකාරය - Enable frequency output - උපාංග id - දත්ත අකාරය + Bluetooth ප්‍රතිදානය + Rotation ප්‍රතිදානය සබල කරන්න + උපාංග ID + දත්ත ආකෘතිය + Frequency ප්‍රතිදානය සබල කරන්න + උපාංග ID + දත්ත ආකෘතිය Bluetooth අවසර පරික්‍ෂා ක° Network අවසර පරික්‍ෂා කරන්න - වෙනත් සැකසුම් - පසුකර වේලාවන් UTC මගින් - ස්වයං දත්ත යාවත්කාලීනය - radar sweep සජීවිකරණය සබල කරන්න - Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න - රතු රාත්‍රී පෙරහන සබල කරන්න + වෙනත් + UTC වේලාව + දත්ත ස්වයං යාවත්කාලීන + Radar sweep + සංවේදක + රාත්‍රී පෙරහන උස් උද්දීපනය අඩු < %1$d° diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index a6754918..8c1b66bf 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -13,12 +13,16 @@ Налаштування - Тип: %s - Виберіть тип супутника + Режими: %s + Виберіть режими Id - Назва Очистити Очистити всі Вибрати всі + Обрані + Доступні + Обрані (%1$d) + Доступні (%1$d) Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена Увага\! @@ -26,7 +30,7 @@ У цьому додатку перелічено понад 9000 супутників. Немає сенсу відстежувати їх усі одночасно. \n\nЗавжди намагайтеся звузити список лише до тих, - які вас цікавлять, за допомогою пошуку та селектора типів. + які вас цікавлять, за допомогою пошуку та вибору режимів. Фільтр прольотів @@ -35,7 +39,7 @@ Вікно AOS Інвертувати вікно AOS DeepSpace (період >225хв) - Виберіть тип модуляції + Виберіть режими Піднес.: %.1f° Висота: %.0f км @@ -96,11 +100,11 @@ QTH Некоректний локатор QTH Позицію успішно оновлено - Налаштування місцезнаходження станції - Широта місцезнаходження станції - Довгота місцезнаходження станції - Налаштування QTH локатора - Задати позицію за QTH локатором + Позиція станції + Широта станції + Довгота станції + Локатор QTH + Позиція за локатором Супутникові дані Супутників: %s @@ -110,39 +114,52 @@ Очистити Дані успішно очищено Оновлення успішне + Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv). + Трансивери не імпортовано. Потрібен SatNOGS (.json). - Custom data sources - Custom TLE URL - Custom transceivers URL + Власні джерела + URL TLE + URL трансиверів + URL (HTTPS) - Data output - Network + Вивід даних + Мережа Bluetooth + CAT - Вивід мережевих даних - Enable rotation output + Керування радіо + CAT керування + Модель радіо + BT адреса TX + BT адреса RX + Назва радіо TX + Назва радіо RX + Увімкнути CAT + + Мережевий вивід + Увімкнути вивід повороту IP:Порт Формат даних - Enable frequency output + Увімкнути вивід частоти IP:Порт Формат даних - Вивід даних Bluetooth - Enable rotation output - Id пристрою + Вивід Bluetooth + Увімкнути вивід повороту + ID пристрою Формат даних - Enable frequency output - Id пристрою + Увімкнути вивід частоти + ID пристрою Формат даних Перевірте дозвіл Bluetooth Перевірте дозвіл мережі - Інші налаштування - Показувати час в UTC - Автоматичне оновлення даних - Показувати анімацію радара - Використовувати сенсори пристрою - Увімкнути червоний нічний фільтр + Інше + Час в UTC + Автооновлення даних + Анімація радара + Сенсори + Нічний фільтр Підсвічування висоти Низько < %1$d° diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index dc7769ff..0d416694 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -67,6 +67,8 @@ 清空 全部清空 全选 + 已选 (%1$d) + 可选 (%1$d) 请确保您的搜索查询正确且数据库已更新 警告\! 此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星 @@ -294,4 +296,8 @@ \n• BG7NTA 该应用程序不提供任何保修. + 频率偏移 (Hz) + 范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。 + 雷达罗盘偏移 + 雷达罗盘偏移 (仰角) diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 6de320f0..24b2b0ce 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -68,6 +68,8 @@ Clear Clear all Select all + Selected (%1$d) + Available (%1$d) "Make sure your search query is correct and the DB is updated" Warning\! @@ -327,4 +329,8 @@ The app comes with no warranty CW decoding uses the DeepCW model by e04, licensed under AGPL-3.0-only.\nhttps://github.com/e04/deepcw-engine + Frequency offset (Hz) + Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it. + Radar compass offset + Radar compass offset (elev) diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt index 751fc60a..88bd5615 100644 --- a/fastlane/metadata/android/en-US/changelogs/default.txt +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -1,2 +1,7 @@ -* Added required tweaks to support Android 17 (API 37) -* Added ACCESS_LOCAL_NETWORK permission check to support network data output +* Added AMSAT status tracking page by MCKero6423 (#234) +* Tweaked Radar track on reposition by atsunatsu (#235) +* Tweaked linear transponder calc by atsunatsu (#236) +* Added saving per-sat doppler offset by atsunatsu (#237) +* Added custom freq offset setting to network reporting +* Added ability to customize data sources via import +* Fixed star chart in README by PingouinFerreux (#240) diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt index 79ec7bb9..1052fa2a 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt @@ -608,7 +608,7 @@ private fun MutualPassCard( modifier = Modifier.fillMaxWidth() ) { Icon( - painter = painterResource(id = R.drawable.ic_radar), + painter = painterResource(id = R.drawable.ic_satellite), contentDescription = null, modifier = Modifier.height(16.dp).width(16.dp) ) diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt index cc1c37ef..c774ff1f 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt @@ -58,20 +58,13 @@ import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.core.presentation.SharedDialog +import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.ElevationHighColor import com.rtbishop.look4sat.core.presentation.ElevationLowColor import com.rtbishop.look4sat.core.presentation.elevationColor +import com.rtbishop.look4sat.core.domain.source.Sources import kotlin.math.roundToInt -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" -) - private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240) private const val dayMinutes = 24 * 60 private const val minuteStep = 15 @@ -147,7 +140,7 @@ internal fun PassesFilterDialog( ) cancel() } - SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { + ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { SliderRow( title = stringResource(R.string.pass_filter_elev), value = elevationValueNew.doubleValue, @@ -372,7 +365,7 @@ internal fun RadiosDialog(modes: List, cancel: () -> Unit, accept: (List selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode } val onAccept = { accept(selected.value.toList()).also { cancel() } } - SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) { + ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) { LazyVerticalGrid( columns = GridCells.Adaptive(240.dp), modifier = Modifier @@ -381,7 +374,7 @@ internal fun RadiosDialog(modes: List, cancel: () -> Unit, accept: (List horizontalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp) ) { - itemsIndexed(allModes) { index, item -> + itemsIndexed(Sources.satelliteModes) { index, item -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt index 82a5bdc3..f9c39dfa 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt @@ -71,10 +71,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.ScreenColumn -import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SwipeableItem import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TopBar +import com.rtbishop.look4sat.core.presentation.WhatsNewDialog import com.rtbishop.look4sat.core.presentation.elevationColor import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.isVerticalLayout @@ -135,26 +135,14 @@ private fun PassesScreen( } if (uiState.shouldSeeWhatsNew) { val dismiss = { onAction(PassesAction.DismissWhatsNew) } - SharedDialog( - title = stringResource(R.string.pass_whatsnew_title), - onDismissRequest = dismiss, - onAccept = dismiss, - titleFontSize = 18 - ) { padding -> - Text( - text = stringResource(R.string.pass_whatsnew_message), - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = padding) - ) - } + WhatsNewDialog(onDismiss = dismiss) } ScreenColumn( topBar = { isVerticalLayout -> TopBar( isVerticalLayout = isVerticalLayout, startAction = { - IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter) + IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios) }, topInfo = { TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos) @@ -163,7 +151,7 @@ private fun PassesScreen( NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc) }, endAction = { - IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios) + IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter) } ) } @@ -290,6 +278,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str } } +private fun displayLocale(): Locale { + val locale = Locale.getDefault() + return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH +} + @Preview(showBackground = true) @Composable private fun DeepSpacePassPreview() { @@ -316,12 +309,13 @@ private fun PassItem( isVerticalLayout: Boolean = true, isUtc: Boolean = false ) { + val passSatId = stringResource(id = R.string.pass_satId, pass.catNum) val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp val timeZone = remember(isUtc) { if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() } val sdfTime = remember(isUtc) { - SimpleDateFormat("HH:mm:ss", Locale.getDefault()).also { it.timeZone = timeZone } + SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone } } val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) } val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) } @@ -341,6 +335,10 @@ private fun PassItem( .padding(horizontal = horizontalPadding, vertical = 4.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "$passSatId - ", + color = MaterialTheme.colorScheme.primary + ) Text( text = pass.name, modifier = Modifier @@ -350,7 +348,7 @@ private fun PassItem( fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.onSurface ) val elevColor = elevationColor(pass.maxElevation) Icon( diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt index a4ab8977..db9132c2 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt @@ -34,6 +34,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -42,6 +45,7 @@ import java.util.Calendar import java.util.Date import java.util.Locale import java.util.TimeZone +import kotlin.time.Duration.Companion.milliseconds class PassesViewModel( private val satelliteRepo: ISatelliteRepo, @@ -61,7 +65,7 @@ class PassesViewModel( aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute, invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow, showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace, - modes = settingsRepo.passesSettings.value.selectedModes, + modes = settingsRepo.selectedSatModes.value, shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew ) ) @@ -87,33 +91,43 @@ class PassesViewModel( } } } - // Main tick loop: reacts to new passes, then ticks every second viewModelScope.launch { - satelliteRepo.passes.collectLatest { allPasses -> - while (isActive) { - val timeNow = System.currentTimeMillis() - val isUtc = _uiState.value.isUtc - val showDeepSpace = _uiState.value.showDeepSpace - val filtered = allPasses - .let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } } - val processed = computePassProgress(filtered, timeNow) - val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) - val sunTimes = computeSunTimes(processed, isUtc) - val grouped = groupPasses(processed, isUtc) - _uiState.update { - it.copy( - itemsList = processed, - groupedPasses = grouped, - sunTimes = sunTimes, - nextPass = nextPass, - nextTime = nextTime, - isNextTimeAos = isAos - ) - } - delay(1000) - } + settingsRepo.selectedSatModes.collectLatest { modes -> + _uiState.update { it.copy(modes = modes) } } } + // Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time + // computations run once per restart, progress/countdown are calculated every second. + viewModelScope.launch { + combine( + satelliteRepo.passes, + settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(), + settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged() + ) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) } + .collectLatest { (allPasses, isUtc, showDeepSpace) -> + val filtered = if (showDeepSpace) allPasses + else allPasses.filter { !it.isDeepSpace } + // Expensive: recompute sun times once per items/UTC change, not every second + val sunTimes = computeSunTimes(filtered, isUtc) + _uiState.update { it.copy(sunTimes = sunTimes) } + while (isActive) { + val timeNow = System.currentTimeMillis() + val processed = computePassProgress(filtered, timeNow) + val grouped = groupPasses(processed, isUtc) + val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) + _uiState.update { + it.copy( + itemsList = processed, + groupedPasses = grouped, + nextPass = nextPass, + nextTime = nextTime, + isNextTimeAos = isAos + ) + } + delay(1000.milliseconds) + } + } + } } fun onAction(action: PassesAction) { @@ -128,21 +142,9 @@ class PassesViewModel( aosStartMinute = action.aosStartMinute, aosEndMinute = action.aosEndMinute, invertAosTimeWindow = action.invertAosTimeWindow, - showDeepSpace = action.showDeepSpace, - modes = _uiState.value.modes - ) - is PassesAction.FilterRadios -> - applyFilter( - hoursAhead = _uiState.value.hours, - minElevation = _uiState.value.elevation, - lowElevation = _uiState.value.lowElevation, - highElevation = _uiState.value.highElevation, - aosStartMinute = _uiState.value.aosStartMinute, - aosEndMinute = _uiState.value.aosEndMinute, - invertAosTimeWindow = _uiState.value.invertAosTimeWindow, - showDeepSpace = _uiState.value.showDeepSpace, - modes = action.modes + showDeepSpace = action.showDeepSpace ) + is PassesAction.FilterRadios -> setModesFilter(action.modes) PassesAction.RefreshPasses -> refreshPasses() PassesAction.TogglePassesDialog -> _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) } @@ -153,14 +155,20 @@ class PassesViewModel( } } - /** Returns a locale-appropriate date format for pass grouping headers. */ + private fun displayLocale(): Locale { + val locale = Locale.getDefault() + return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH + } + + /** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */ private fun dateFormat(tz: TimeZone): SimpleDateFormat { - val pattern = if (Locale.getDefault().language == "zh") { + val locale = displayLocale() + val pattern = if (locale.language == Locale.CHINESE.language) { "yyyy'年'M'月'd'日' EEEE" } else { "EEE, dd MMM yyyy" } - return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz } + return SimpleDateFormat(pattern, locale).also { it.timeZone = tz } } // Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace. @@ -171,7 +179,7 @@ class PassesViewModel( val stationPos = settingsRepo.stationPosition.value val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() val sdfDate = dateFormat(tz) - val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz } + val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz } val result = LinkedHashMap>() // DeepSpace group always shows today's sun times if (passes.any { it.isDeepSpace }) { @@ -264,8 +272,7 @@ class PassesViewModel( aosStartMinute: Int, aosEndMinute: Int, invertAosTimeWindow: Boolean, - showDeepSpace: Boolean, - modes: List + showDeepSpace: Boolean ) = viewModelScope.launch { settingsRepo.setPassesSettings( PassesSettings( @@ -274,8 +281,7 @@ class PassesViewModel( minElevation, aosStartMinute, aosEndMinute, - invertAosTimeWindow, - modes + invertAosTimeWindow ) ) settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) } @@ -288,10 +294,10 @@ class PassesViewModel( aosStartMinute = aosStartMinute, aosEndMinute = aosEndMinute, invertAosTimeWindow = invertAosTimeWindow, - showDeepSpace = showDeepSpace, - modes = modes + showDeepSpace = showDeepSpace ) } + val modes = settingsRepo.selectedSatModes.value satelliteRepo.calculatePasses( time = System.currentTimeMillis(), hoursAhead = hoursAhead, @@ -303,6 +309,20 @@ class PassesViewModel( ) } + private fun setModesFilter(modes: List) = viewModelScope.launch { + settingsRepo.setSelectedSatModes(modes) + _uiState.update { it.copy(modes = modes) } + satelliteRepo.calculatePasses( + time = System.currentTimeMillis(), + hoursAhead = _uiState.value.hours, + minElevation = _uiState.value.elevation, + aosStartMinute = _uiState.value.aosStartMinute, + aosEndMinute = _uiState.value.aosEndMinute, + invertAosTimeWindow = _uiState.value.invertAosTimeWindow, + modes = modes + ) + } + private fun refreshPasses() = viewModelScope.launch { val settings = settingsRepo.passesSettings.value satelliteRepo.calculatePasses( @@ -312,7 +332,7 @@ class PassesViewModel( aosStartMinute = settings.aosStartMinute, aosEndMinute = settings.aosEndMinute, invertAosTimeWindow = settings.invertAosTimeWindow, - modes = settings.selectedModes + modes = settingsRepo.selectedSatModes.value ) } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index cca583cb..2266dcff 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -284,7 +284,8 @@ private fun PagerCard( orbitalPos = uiState.orbitalPos, cw = uiState.cw, onAction = onAction, - requestMicPermission = requestMicPermission + requestMicPermission = requestMicPermission, + calculatorOffsetKHz = uiState.calculatorOffsetKHz ) RadarPage.Sstv -> SstvPage( sstv = uiState.sstv, @@ -352,6 +353,7 @@ private fun RadarCard( azimElev = uiState.orientationValues, shouldShowSweep = uiState.shouldShowSweep, shouldUseCompass = uiState.shouldUseCompass, + shouldFlipRadar = uiState.shouldFlipRadar, modifier = Modifier.align(Alignment.Center), sunPosition = uiState.sunPosition, moonPosition = uiState.moonPosition, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index aa3b757b..c4d68936 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -58,6 +58,7 @@ data class RadarState( val satTrack: List = emptyList(), val shouldShowSweep: Boolean = false, val shouldUseCompass: Boolean = false, + val shouldFlipRadar: Boolean = false, val sunPosition: CelestialComputer.SunPosition? = null, val moonPosition: CelestialComputer.MoonPosition? = null, val transceivers: TransceiverSubState = TransceiverSubState(), @@ -69,7 +70,9 @@ data class RadarState( val mutualEndTime: Long = 0L, val mutualMaxElev: Double = 10.0, val mutualLabelA: String = "你", - val mutualLabelB: String = "友台" + val mutualLabelB: String = "友台", + // Upstream Doppler calculator offset (merged from rt-bishop) + val calculatorOffsetKHz: String = "" ) enum class SstvStatus { Idle, Recording } @@ -124,4 +127,6 @@ sealed interface RadarAction { data object CwReset : RadarAction data class CwToggleExpanded(val expanded: Boolean) : RadarAction data class CwPermissionResult(val granted: Boolean) : RadarAction + // Calculator actions + data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt index ceed8c7d..4aab1106 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt @@ -79,6 +79,7 @@ fun RadarViewCompose( azimElev: Pair, shouldShowSweep: Boolean, shouldUseCompass: Boolean, + shouldFlipRadar: Boolean, modifier: Modifier = Modifier, sunPosition: CelestialComputer.SunPosition? = null, moonPosition: CelestialComputer.MoonPosition? = null, @@ -146,7 +147,9 @@ fun RadarViewCompose( cachedSweepBrush = makeSweepBrush(center, primaryColor) cachedSweepColor = primaryColor } - rotate(if (shouldUseCompass) -azimElev.first else 0f) { + val baseRotation = if (shouldUseCompass) -azimElev.first else 0f + val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation + rotate(rotation) { if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) } drawRadar(radius, radarColor) drawElevationLabels(radius, primaryColor, measurer) diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index b2716c4e..b32a440b 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -67,13 +67,16 @@ class RadarViewModel( private val showToast: IShowToast ) : ViewModel() { - private val stationPos = settingsRepo.stationPosition.value - private val magDeclination = sensorsRepo.getMagDeclination(stationPos) + private var stationPos = settingsRepo.stationPosition.value + private var magDeclination = sensorsRepo.getMagDeclination(stationPos) + private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset + private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev private var transponders: List = emptyList() private var sstvDecoder: SstvDecoder? = null private var sstvRecordingJob: Job? = null private var cwDecoder: ICwDecoder? = null private var cwListeningJob: Job? = null + private var sensorCollectionJob: Job? = null // Celestial positions change slowly, recompute at most once per minute private var lastCelestialUpdateMs = 0L @@ -92,33 +95,67 @@ class RadarViewModel( val uiState: StateFlow = _uiState init { - collectCompassSensor() - collectSettingsChanges() + collectSettingsChanges() // also handles initial sensor subscription + collectStationPositionChanges() collectPassAndStartTickLoop() collectRadioTrackingState() } - private fun collectCompassSensor() { - if (!settingsRepo.otherSettings.value.stateOfSensors) return - viewModelScope.launch { + // Starts sensor collection if not already running. + private fun startSensorCollection() { + if (sensorCollectionJob?.isActive == true) return + sensorCollectionJob = viewModelScope.launch { sensorsRepo.enableSensor() sensorsRepo.sensorData.collect { data -> - val orientationValues = (data.first + magDeclination) to data.second + val orientationValues = + (data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev) _uiState.update { it.copy(orientationValues = orientationValues) } } } } + // Stops sensor collection and disables the hardware sensor. + private fun stopSensorCollection() { + sensorCollectionJob?.cancel() + sensorCollectionJob = null + sensorsRepo.disableSensor() + } + private fun collectSettingsChanges() { viewModelScope.launch { - settingsRepo.otherSettings.collectLatest { settings -> + settingsRepo.otherSettings.collect { settings -> + compassOffset = settings.radarCompassOffset + compassOffsetElev = settings.radarCompassOffsetElev _uiState.update { it.copy( isUtc = settings.stateOfUtc, shouldShowSweep = settings.stateOfSweep, - shouldUseCompass = settings.stateOfSensors + shouldUseCompass = settings.stateOfSensors, + shouldFlipRadar = settings.radarCompassOffsetElev < 0f ) } + // Reactively wire sensor hardware to the compass setting + when { + settings.stateOfSensors -> startSensorCollection() + else -> stopSensorCollection() + } + } + } + } + + private fun collectStationPositionChanges() { + viewModelScope.launch { + settingsRepo.stationPosition.collect { newPos -> + if (stationPos != newPos) { + stationPos = newPos + magDeclination = sensorsRepo.getMagDeclination(stationPos) + lastCelestialUpdateMs = 0L // force celestial recompute on next tick + val pass = _uiState.value.currentPass ?: return@collect + if (!pass.isDeepSpace) { + val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime) + _uiState.update { it.copy(satTrack = track) } + } + } } } } @@ -244,6 +281,7 @@ class RadarViewModel( // OrtSession holds native memory; it must be released explicitly. cwDecoder?.close() cwDecoder = null + stopSensorCollection() } fun onAction(action: RadarAction) { @@ -253,7 +291,17 @@ class RadarViewModel( // Compute toggle state before the update so we don't read post-update value val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid val newUuid = if (isTogglingOff) null else action.uuid - _uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) } + // Load the saved offset for the newly selected satellite + val offsetKHz = if (!isTogglingOff) { + transponders.find { it.uuid == action.uuid } + ?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: "" + } else "" + _uiState.update { + it.copy( + transceivers = it.transceivers.copy(selectedUuid = newUuid), + calculatorOffsetKHz = offsetKHz + ) + } // Only update the tracking service when selecting a different transponder to // avoid resetting a user-adjusted TX base on re-expand if (!isTogglingOff) { @@ -298,7 +346,7 @@ class RadarViewModel( _uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) } settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) } } - RadarAction.SstvReset -> { + is RadarAction.SstvReset -> { sstvDecoder?.clearPixels() _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } } @@ -317,6 +365,15 @@ class RadarViewModel( is RadarAction.CwToggleExpanded -> { _uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) } } + is RadarAction.ChangeCalculatorOffset -> { + val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid -> + transponders.find { it.uuid == uuid }?.catnum + } + if (catnum != null) { + settingsRepo.setSatelliteOffset(catnum, action.offsetKHz) + } + _uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) } + } } } @@ -494,7 +551,7 @@ class RadarViewModel( // - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs // - 0.35-0.40: RECOMMENDED for typical phone/mic inputs // - 0.50-0.60: Conservative, best for noisy environments - private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default + private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default // Enable/disable RMS normalization entirely. Set false for direct receiver outputs. private const val SSTV_ENABLE_RMS_NORMALIZATION = true @@ -517,7 +574,7 @@ class RadarViewModel( // - Look4SatLimited: caps predicted lines to reduce visible vertical collapse. // - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior). // Ask users to compare both if they report line-skipping/compression artifacts. - private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited + private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible // Debug: Return scope visualization in frames (increases per-frame memory usage). private const val SSTV_INCLUDE_SCOPE = false @@ -529,7 +586,6 @@ class RadarViewModel( 218.1, 225.7, 233.6, 241.8, 250.3 ) - fun factory(container: IMainContainer) = viewModelFactory { initializer { RadarViewModel( diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt index c0ceb48c..0144c504 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt @@ -53,10 +53,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.OutlinedText import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.infiniteMarquee import kotlin.math.roundToInt @@ -96,14 +96,14 @@ internal fun SstvPage( if (showModeDialog.value) { val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes } val dismiss = { showModeDialog.value = false } - SharedDialog( + ConfirmDialog( title = "SSTV Mode", - onDismissRequest = dismiss, - onAccept = dismiss - ) { _ -> + onAccept = dismiss, + onCancel = dismiss + ) { LazyColumn( modifier = Modifier - .fillMaxHeight(0.7f) + .fillMaxHeight(0.69f) .background(MaterialTheme.colorScheme.background), verticalArrangement = Arrangement.spacedBy(1.dp) ) { diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index 756694c1..700febdc 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -21,6 +21,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.rememberScrollState @@ -31,8 +32,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -43,6 +47,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.FilterChip @@ -51,11 +56,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -67,6 +74,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -76,6 +84,7 @@ import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator +import com.rtbishop.look4sat.core.domain.utility.TransponderMapper import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.formatFrequency @@ -128,10 +137,12 @@ fun CalculatorPage( cw: CwSubState, onAction: (RadarAction) -> Unit, requestMicPermission: () -> Unit = {}, + calculatorOffsetKHz: String = "", modifier: Modifier = Modifier ) { val calculatorTransceivers = remember(transceivers) { transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) + .let(DopplerFrequencyCalculator::deduplicateTransponders) } val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { calculatorTransceivers.firstOrNull { it.uuid == selectedUuid } @@ -179,6 +190,8 @@ fun CalculatorPage( DopplerFrequencyCalculator( transponder = selectedTransceiver, orbitalPos = orbitalPos, + offsetKHz = calculatorOffsetKHz, + onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) }, modifier = Modifier.fillMaxWidth() ) } @@ -463,10 +476,11 @@ private fun ExpandedRadioControl( ) Spacer(modifier = Modifier.height(4.dp)) FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + maxItemsInEachRow = 5, modifier = Modifier.fillMaxWidth() ) { - val chipModifier = Modifier.width(64.dp) + val chipModifier = Modifier.weight(1f) FilterChip( selected = radioControl.ctcssTone == null, onClick = { onAction(RadarAction.SetCtcssTone(null)) }, @@ -530,153 +544,362 @@ private fun ExpandedRadioControl( } } +private enum class EditedField { TX, PASSBAND, RX } + @Composable private fun DopplerFrequencyCalculator( transponder: SatRadio, orbitalPos: OrbitalPos?, + offsetKHz: String = "", + onOffsetChange: (String) -> Unit = {}, modifier: Modifier = Modifier ) { if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return - var txInputMHz by remember { mutableStateOf("") } - var rxInputMHz by remember { mutableStateOf("") } - var offsetKHz by remember { mutableStateOf("") } - var lastEditedBy by remember { mutableStateOf(EditedField.TX) } + var lastEditedField by remember { mutableStateOf(EditedField.TX) } + var txFrequencyHz by remember { mutableStateOf(0L) } + var rxFrequencyHz by remember { mutableStateOf(0L) } + var passbandPosition by remember { mutableStateOf(0.5f) } + var stepSizeKHz by remember { mutableIntStateOf(1) } val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L - // Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field + val txLow = transponder.uplinkLow ?: return + val txHigh = transponder.uplinkHigh ?: return + val rxLow = transponder.downlinkLow ?: return + val rxHigh = transponder.downlinkHigh ?: return + val txRange = txHigh - txLow + val rxRange = rxHigh - rxLow + if (txRange <= 0L || rxRange <= 0L) return + + // 原始转发器值(逆转多普勒),用于 passband 映射计算 + val rawTxLow = orbitalPos.getDownlinkFreq(txLow) + val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh) + val rawRxLow = orbitalPos.getUplinkFreq(rxLow) + val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh) + val rawTxRange = rawTxHigh - rawTxLow + val rawRxRange = rawRxHigh - rawRxLow + val rawTransponder = transponder.copy( + uplinkLow = rawTxLow, uplinkHigh = rawTxHigh, + downlinkLow = rawRxLow, downlinkHigh = rawRxHigh + ) + + // 初始化 + LaunchedEffect(Unit) { + if (txFrequencyHz == 0L) { + txFrequencyHz = txLow + txRange / 2 + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: (rxLow + rxRange / 2) + } + } + + // 实时重算:卫星移动时,锚点侧不变,重算另一侧 LaunchedEffect(orbitalPos) { - if (lastEditedBy == EditedField.TX) { - val txMHz = txInputMHz.toDoubleOrNull() - if (txMHz != null && txMHz > 0) { - val txHz = (txMHz * 1_000_000).toLong() - val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( - txHz, transponder, orbitalPos, offsetHz - ) - if (rxHz != null) { - rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) - } + if (lastEditedField == EditedField.TX) { + // TX 是锚点,不变;重算 RX + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: rxFrequencyHz + } else if (lastEditedField == EditedField.RX) { + // RX 是锚点,不变;重算 TX + txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + rxFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: txFrequencyHz + } else if (lastEditedField == EditedField.PASSBAND) { + // 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算 + txFrequencyHz = txLow + (passbandPosition * txRange).toLong() + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: rxFrequencyHz + } + } + + val txSliderValue = if (lastEditedField == EditedField.PASSBAND) { + passbandPosition + } else { + ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f) + } + val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) { + ((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f) + } else { + ((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f) + } + + fun updateTx(newTxHz: Long) { + when (lastEditedField) { + EditedField.PASSBAND -> { + // Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算 + val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f) + passbandPosition = pos + txFrequencyHz = txLow + (pos * txRange).toLong() + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: rxFrequencyHz } - } else { - val rxMHz = rxInputMHz.toDoubleOrNull() - if (rxMHz != null && rxMHz > 0) { - val rxHz = (rxMHz * 1_000_000).toLong() - val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( - rxHz, transponder, orbitalPos, offsetHz - ) - if (txHz != null) { - txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) - } + else -> { + txFrequencyHz = newTxHz.coerceIn(txLow, txHigh) + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: rxFrequencyHz + } + } + } + + fun updateRx(newRxHz: Long) { + when (lastEditedField) { + EditedField.PASSBAND -> { + // Passband 模式:从 RX 反推 TX 位置,TX 用地面频率 + val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f) + val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder) + passbandPosition = if (txFromRx != null) { + ((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f) + } else pos + txFrequencyHz = txLow + (passbandPosition * txRange).toLong() + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: rxFrequencyHz + } + else -> { + rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh) + txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + rxFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: txFrequencyHz } } } Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = modifier.fillMaxWidth().padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = stringResource(R.string.radar_doppler_calc), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.radar_doppler_info), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - // Offset input - OutlinedTextField( - value = offsetKHz, - onValueChange = { newVal -> - offsetKHz = newVal - val newOffsetHz = newVal.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L - // Trigger recompute based on last edited field - if (lastEditedBy == EditedField.TX) { - val txMHz = txInputMHz.toDoubleOrNull() - if (txMHz != null && txMHz > 0) { - val txHz = (txMHz * 1_000_000).toLong() - val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( - txHz, transponder, orbitalPos, newOffsetHz - ) - rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else "" - } - } else { - val rxMHz = rxInputMHz.toDoubleOrNull() - if (rxMHz != null && rxMHz > 0) { - val rxHz = (rxMHz * 1_000_000).toLong() - val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( - rxHz, transponder, orbitalPos, newOffsetHz - ) - txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else "" - } + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // 左半边: TX + RX 按钮 + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilterChip( + selected = lastEditedField == EditedField.TX, + onClick = { + when (lastEditedField) { + EditedField.TX -> { + passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f) + lastEditedField = EditedField.PASSBAND + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: rxFrequencyHz + } + EditedField.PASSBAND -> { lastEditedField = EditedField.TX } + else -> { lastEditedField = EditedField.TX } + } + }, + label = { + Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth()) + }, + modifier = Modifier.weight(1f).fillMaxHeight() + ) + FilterChip( + selected = lastEditedField == EditedField.RX, + onClick = { + when (lastEditedField) { + EditedField.RX -> { + txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + rxFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: txFrequencyHz + passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f) + lastEditedField = EditedField.PASSBAND + } + EditedField.PASSBAND -> { + lastEditedField = EditedField.RX + txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + rxFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: txFrequencyHz + } + else -> { lastEditedField = EditedField.RX } + } + }, + label = { + Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth()) + }, + modifier = Modifier.weight(1f).fillMaxHeight() + ) } - }, - label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), - modifier = Modifier.fillMaxWidth() - ) + // 右半边: Offset 输入框(带细线边框) + Box( + modifier = Modifier + .weight(1f) + .height(48.dp) + .border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small), + contentAlignment = Alignment.Center + ) { + BasicTextField( + value = offsetKHz, + onValueChange = onOffsetChange, + singleLine = true, + textStyle = TextStyle( + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ), + decorationBox = { innerTextField -> + if (offsetKHz.isEmpty()) { + Text( + text = "Offset (kHz)", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } + ) + } + } - // TX and RX inputs on the same row - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - // TX input → compute RX - OutlinedTextField( - value = txInputMHz, - onValueChange = { newVal -> - txInputMHz = newVal - lastEditedBy = EditedField.TX - val mhz = newVal.toDoubleOrNull() - if (mhz != null && mhz > 0) { - val txHz = (mhz * 1_000_000).toLong() - val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( - txHz, transponder, orbitalPos, offsetHz + // TX 行 + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedButton( + onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) }, + modifier = Modifier.width(32.dp).height(28.dp), + contentPadding = PaddingValues(0.dp), + shape = MaterialTheme.shapes.extraSmall + ) { Text("−", fontSize = 14.sp) } + + Slider( + value = txSliderValue, + onValueChange = { norm -> + val rawFreq = txLow + (txRange * norm).toLong() + val snapped = ((rawFreq + 500) / 1000L) * 1000L + updateTx(snapped.coerceIn(txLow, txHigh)) + }, + valueRange = 0f..1f, + modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp) + ) + + OutlinedButton( + onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) }, + modifier = Modifier.width(32.dp).height(28.dp), + contentPadding = PaddingValues(0.dp), + shape = MaterialTheme.shapes.extraSmall + ) { Text("+", fontSize = 14.sp) } + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = " MHz", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) - rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else "" - } else if (newVal.isEmpty()) { - rxInputMHz = "" } - }, - label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) + Text( + text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End + ) + } + } + + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) ) - // RX input → compute TX - OutlinedTextField( - value = rxInputMHz, - onValueChange = { newVal -> - rxInputMHz = newVal - lastEditedBy = EditedField.RX - val mhz = newVal.toDoubleOrNull() - if (mhz != null && mhz > 0) { - val rxHz = (mhz * 1_000_000).toLong() - val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( - rxHz, transponder, orbitalPos, offsetHz + // RX 行 + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedButton( + onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) }, + modifier = Modifier.width(32.dp).height(28.dp), + contentPadding = PaddingValues(0.dp), + shape = MaterialTheme.shapes.extraSmall + ) { Text("−", fontSize = 14.sp) } + + Slider( + value = rxSliderValue, + onValueChange = { norm -> + val rawFreq = rxLow + (rxRange * norm).toLong() + val snapped = ((rawFreq + 500) / 1000L) * 1000L + updateRx(snapped.coerceIn(rxLow, rxHigh)) + }, + valueRange = 0f..1f, + modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp) + ) + + OutlinedButton( + onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) }, + modifier = Modifier.width(32.dp).height(28.dp), + contentPadding = PaddingValues(0.dp), + shape = MaterialTheme.shapes.extraSmall + ) { Text("+", fontSize = 14.sp) } + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = " MHz", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) - txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else "" - } else if (newVal.isEmpty()) { - txInputMHz = "" } - }, - label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) - ) + Text( + text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End + ) + } + } } - } } -private enum class EditedField { TX, RX } - @Composable private fun CwDecoderPanel( cw: CwSubState, @@ -805,3 +1028,4 @@ private fun transceiverTitle(radio: SatRadio): String { private val FREQ_ADJUSTMENTS = listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k") + diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt index 025fd100..e077f44a 100644 --- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt +++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt @@ -39,27 +39,27 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.core.presentation.SharedDialog @Preview(showBackground = true) @Composable -private fun MultiTypesDialogPreview() { - val types = listOf("Amateur", "Geostationary", "Military", "Weather") - MainTheme { MultiTypesDialog(types, emptyList(), {}) {} } +private fun MultiModesDialogPreview() { + val modes = listOf("FM", "APT", "SSTV", "BPSK") + MainTheme { MultiModesDialog(modes, emptyList(), {}) {} } } @Composable -internal fun MultiTypesDialog( - allTypes: List, types: List, cancel: () -> Unit, accept: (List) -> Unit +internal fun MultiModesDialog( + allModes: List, modes: List, cancel: () -> Unit, accept: (List) -> Unit ) { - val selected = remember { mutableStateOf(types.toSet()) } - val toggle = { type: String -> - selected.value = if (type in selected.value) selected.value - type else selected.value + type + val selected = remember { mutableStateOf(modes.toSet()) } + val toggle = { mode: String -> + selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode } val onAccept = { accept(selected.value.toList()) } - SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) { + ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) { LazyVerticalGrid( columns = GridCells.Adaptive(240.dp), modifier = Modifier @@ -68,7 +68,7 @@ internal fun MultiTypesDialog( horizontalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp) ) { - itemsIndexed(allTypes) { index, item -> + itemsIndexed(allModes) { index, item -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt index ec0270e3..aacebe7c 100644 --- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt +++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt @@ -62,10 +62,11 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.IconCard +import com.rtbishop.look4sat.core.presentation.InfoDialog +import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.isVerticalLayout @@ -87,27 +88,27 @@ private fun SatellitesScreen( navigateUp: () -> Unit ) { if (uiState.isDialogShown) { - MultiTypesDialog( - allTypes = uiState.typesList, - types = uiState.currentTypes, - cancel = { onAction(SatellitesAction.ToggleTypesDialog) }, - accept = { onAction(SatellitesAction.SelectTypes(it)) } + MultiModesDialog( + allModes = uiState.modesList, + modes = uiState.currentModes, + cancel = { onAction(SatellitesAction.ToggleModesDialog) }, + accept = { onAction(SatellitesAction.SelectModes(it)) } ) } if (uiState.shouldSeeWarning) { val dismiss = { onAction(SatellitesAction.DismissWarning) } - SharedDialog( + InfoDialog( title = stringResource(R.string.sat_warning_title), - onDismissRequest = dismiss, - onAccept = dismiss, - titleFontSize = 18 - ) { padding -> + onDismiss = dismiss, + onAccept = dismiss + ) { Text( text = stringResource(R.string.sat_warning_message), fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = padding) + modifier = Modifier.padding(horizontal = LocalSpacing.current.large) ) + Spacer(modifier = Modifier.height(0.dp)) } } @@ -122,9 +123,9 @@ private fun SatellitesScreen( Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) { if (isVerticalLayout()) { TopBar { - TypeCard( - types = uiState.currentTypes, - onClick = { onAction(SatellitesAction.ToggleTypesDialog) }, + ModeCard( + modes = uiState.currentModes, + onClick = { onAction(SatellitesAction.ToggleModesDialog) }, modifier = Modifier.weight(1f) ) PrimaryIconCard( @@ -156,9 +157,9 @@ private fun SatellitesScreen( resId = R.drawable.ic_done, modifier = Modifier.semantics { contentDescription = primCardCd } ) - TypeCard( - types = uiState.currentTypes, - onClick = { onAction(SatellitesAction.ToggleTypesDialog) }, + ModeCard( + modes = uiState.currentModes, + onClick = { onAction(SatellitesAction.ToggleModesDialog) }, modifier = Modifier.weight(1f) ) SearchBar( @@ -240,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi } @Composable -private fun TypeCard(types: List, onClick: () -> Unit, modifier: Modifier = Modifier) { - val typesText = if (types.isEmpty()) "All" else types.joinToString(", ") +private fun ModeCard(modes: List, onClick: () -> Unit, modifier: Modifier = Modifier) { + val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ") ElevatedCard(modifier = modifier) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -251,12 +252,12 @@ private fun TypeCard(types: List, onClick: () -> Unit, modifier: Modifie .clickable { onClick() }) { Spacer(Modifier) Icon( - painter = painterResource(id = R.drawable.ic_tags), + painter = painterResource(id = R.drawable.ic_radios), contentDescription = null, tint = MaterialTheme.colorScheme.primary ) Text( - text = stringResource(R.string.sat_type_hint, typesText), + text = stringResource(R.string.sat_type_hint, modesText), fontSize = 16.sp, fontWeight = FontWeight.Medium, modifier = Modifier @@ -325,9 +326,54 @@ private fun SatellitesPreview() { @Composable private fun SatellitesCard(items: List, onSelected: (Int, Boolean) -> Unit) { + val selectedItems = items.filter { it.isSelected } + val availableItems = items.filterNot { it.isSelected } + LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) { - items(items = items, key = { item -> item.catnum }) { entry -> - Satellite(entry, onSelected, Modifier.animateItem()) + stickyHeader(key = "sat_summary_header") { + SummaryHeader( + selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size), + availableText = stringResource(R.string.sat_group_available_count, availableItems.size) + ) + } + + if (selectedItems.isNotEmpty()) { + items(items = selectedItems, key = { item -> item.catnum }) { entry -> + Satellite(entry, onSelected, Modifier.animateItem()) + } + } + + if (availableItems.isNotEmpty()) { + items(items = availableItems, key = { item -> item.catnum }) { entry -> + Satellite(entry, onSelected, Modifier.animateItem()) + } } } } + +@Composable +private fun SummaryHeader(selectedText: String, availableText: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = selectedText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = availableText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + + diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt index c82bc162..e0714c64 100644 --- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt +++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt @@ -24,8 +24,8 @@ data class SatellitesState( val isLoading: Boolean = true, val shouldSeeWarning: Boolean = false, val itemsList: List = emptyList(), - val currentTypes: List = emptyList(), - val typesList: List = emptyList() + val currentModes: List = emptyList(), + val modesList: List = emptyList() ) sealed interface SatellitesAction { @@ -34,7 +34,7 @@ sealed interface 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) : SatellitesAction - data object ToggleTypesDialog : SatellitesAction + data class SelectModes(val modes: List) : SatellitesAction + data object ToggleModesDialog : SatellitesAction data object UnselectAll : SatellitesAction } diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesViewModel.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesViewModel.kt index fdc4b9b3..84ad23c8 100644 --- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesViewModel.kt +++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesViewModel.kt @@ -35,13 +35,13 @@ class SatellitesViewModel( private val settingsRepo: ISettingsRepo ) : ViewModel() { - private val defaultTypes = selectionRepo.getCurrentTypes() + private val defaultModes = selectionRepo.getCurrentModes() private val _uiState = MutableStateFlow( SatellitesState( isLoading = true, shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning, - currentTypes = defaultTypes, - typesList = selectionRepo.getTypesList() + currentModes = defaultModes, + modesList = selectionRepo.getModesList() ) ) val uiState: StateFlow = _uiState @@ -49,7 +49,7 @@ class SatellitesViewModel( init { viewModelScope.launch { selectionRepo.setQuery(String()) - selectionRepo.setTypes(defaultTypes) + selectionRepo.setModes(defaultModes) selectionRepo.getEntriesFlow().collectLatest { items -> _uiState.update { it.copy(isLoading = false, itemsList = items) } } @@ -59,6 +59,11 @@ class SatellitesViewModel( _uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) } } } + viewModelScope.launch { + settingsRepo.selectedSatModes.collectLatest { modes -> + _uiState.update { it.copy(currentModes = modes) } + } + } } fun onAction(action: SatellitesAction) { @@ -68,8 +73,8 @@ class SatellitesViewModel( 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() + is SatellitesAction.SelectModes -> selectModes(action.modes) + SatellitesAction.ToggleModesDialog -> toggleModesDialog() SatellitesAction.UnselectAll -> selectAll(false) } } @@ -86,12 +91,12 @@ class SatellitesViewModel( selectionRepo.setSelection(listOf(id), isTicked.not()) } - private fun selectTypes(types: List) = viewModelScope.launch { - selectionRepo.setTypes(types) - _uiState.update { it.copy(currentTypes = types, isDialogShown = false) } + private fun selectModes(modes: List) = viewModelScope.launch { + selectionRepo.setModes(modes) + _uiState.update { it.copy(currentModes = modes, isDialogShown = false) } } - private fun toggleTypesDialog() { + private fun toggleModesDialog() { _uiState.update { it.copy(isDialogShown = !it.isDialogShown) } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index d377add1..204348f7 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -17,45 +17,58 @@ */ package com.rtbishop.look4sat.feature.settings -import android.bluetooth.BluetoothManager -import android.content.Context +import androidx.compose.animation.core.spring import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource 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 com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.domain.model.RadioControlSettings +import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.presentation.CardButton +import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.R +import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.SharedDialog @Preview(showBackground = true) @@ -70,19 +83,28 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double, val lonValue = rememberSaveable { mutableStateOf(lon.toString()) } val titleText = stringResource(id = R.string.prefs_station_title) val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } } - 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)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), - ) - OutlinedTextField( - value = lonValue.value, - onValueChange = { lonValue.value = it }, - label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), - ) + ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalSpacing.current.large) + ) { + OutlinedTextField( + value = latValue.value, + onValueChange = { latValue.value = it }, + label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = lonValue.value, + onValueChange = { lonValue.value = it }, + label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } Spacer(modifier = Modifier.height(0.dp)) } } @@ -105,35 +127,23 @@ private fun LocatorDialogPreview() { 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) { + ConfirmDialog( + 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)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalSpacing.current.large), ) Spacer(modifier = Modifier.height(0.dp)) } } -@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", - requestCustomSourcesPermission = { onGranted, _ -> onGranted() }, - onImportTle = {}, - onImportTransceivers = {}, - onDismiss = {}, - onSave = { _, _, _, _ -> } - ) - } -} - @Composable fun DataSourcesDialog( useCustomTle: Boolean, @@ -265,6 +275,59 @@ fun DataSourcesDialog( } } +private fun LazyListScope.sourceSection( + sectionKey: String, + label: String, + urls: List>, + onAdd: () -> Unit, + onMoveUp: (Int) -> Unit, + onRemove: (Int) -> Unit, + onUrlChange: (Int, String) -> Unit +) { + item { + Spacer(modifier = Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant) + } + } + itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) -> + val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant + OutlinedTextField( + value = url, + onValueChange = { onUrlChange(index, it) }, + label = { Text("Source URL") }, + leadingIcon = { + IconButton(onClick = { onMoveUp(index) }) { + Icon( + painter = painterResource(R.drawable.ic_arrow), + contentDescription = null, + tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f), + modifier = Modifier.rotate(270f) + ) + } + }, + trailingIcon = { + IconButton(onClick = { onRemove(index) }) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null + ) + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .animateItem(fadeInSpec = spring(), fadeOutSpec = spring()) + ) + } +} + @Preview(showBackground = true) @Composable fun PreviewNetworkOutputDialog() { @@ -279,6 +342,7 @@ fun PreviewNetworkOutputDialog() { frequencyAddress = "127.0.0.1", frequencyPort = "4532", frequencyFormat = $$"F $FREQ", + frequencyOffsetHz = 0L, bluetoothRotatorState = false, bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorName = "Default", @@ -288,7 +352,7 @@ fun PreviewNetworkOutputDialog() { bluetoothFrequencyFormat = $$"F $FREQ" ), onDismiss = {}, - onSave = { _, _, _, _, _, _, _, _ -> } + onSave = { _, _, _, _, _, _, _, _, _ -> } ) } } @@ -299,7 +363,7 @@ fun NetworkOutputDialog( onDismiss: () -> Unit, onSave: ( Boolean, String, String, String, - Boolean, String, String, String + Boolean, String, String, String, Long ) -> Unit ) { val padding = LocalSpacing.current.large @@ -313,16 +377,19 @@ fun NetworkOutputDialog( mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}") } val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) } + val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) } val onAccept = { val (rotIp, rotPort) = splitAddress(rotatorAddress.value) val (freqIp, freqPort) = splitAddress(frequencyAddress.value) + val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L) + .coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ) onSave( rotatorState.value, rotIp, rotPort, rotatorFormat.value, - frequencyState.value, freqIp, freqPort, frequencyFormat.value + frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz ) onDismiss() } - SharedDialog( + ConfirmDialog( title = stringResource(R.string.prefs_net_title), onCancel = onDismiss, onAccept = onAccept @@ -351,6 +418,27 @@ fun NetworkOutputDialog( onFormatChange = { frequencyFormat.value = it }, formatLabel = stringResource(R.string.prefs_net_frequency_format_hint) ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = frequencyOffsetHz.value, + onValueChange = { frequencyOffsetHz.value = it }, + singleLine = true, + label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) }, + supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) }, + trailingIcon = { + IconButton( + onClick = { frequencyOffsetHz.value = "0" }, + enabled = frequencyState.value && frequencyOffsetHz.value != "0" + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = null + ) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = frequencyState.value + ) Spacer(modifier = Modifier.height(12.dp)) } } @@ -379,6 +467,7 @@ fun PreviewBluetoothOutputDialog() { frequencyAddress = "127.0.0.1", frequencyPort = "4532", frequencyFormat = $$"F $FREQ", + frequencyOffsetHz = 0L, bluetoothRotatorState = false, bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorName = "Default", @@ -404,11 +493,15 @@ fun BluetoothOutputDialog( ) { val padding = LocalSpacing.current.large val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) } - val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) } + 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 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, @@ -416,7 +509,7 @@ fun BluetoothOutputDialog( ) onDismiss() } - SharedDialog( + ConfirmDialog( title = stringResource(R.string.prefs_bt_title), onCancel = onDismiss, onAccept = onAccept @@ -497,23 +590,22 @@ private fun OutputChannelSection( } } -@OptIn(ExperimentalLayoutApi::class) @Composable fun RadioControlDialog( initialSettings: RadioControlSettings, + pairedBluetoothDevices: List>, onDismiss: () -> Unit, onSave: (RadioControlSettings) -> Unit ) { - val context = androidx.compose.ui.platform.LocalContext.current - val padding = LocalSpacing.current.large - val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) } + val padding = LocalSpacing.current.large + val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) } val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) } - val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) } - val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) } - val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } - val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } - val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) } - val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) } + val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) } + val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) } + val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } + val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } + val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) } + val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) } val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or "" val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705 @@ -525,159 +617,159 @@ fun RadioControlDialog( } val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM - else RadioControlSettings.BAUD_RATES_YAESU + else RadioControlSettings.BAUD_RATES_YAESU // If current baud rate is not in the new list, default to the first available LaunchedEffect(baudRates) { if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first() } - val pairedDevices: List> = remember { - buildList { - try { - val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - manager.adapter?.bondedDevices?.forEach { - add(Pair(it.name ?: "Unknown", it.address ?: "")) - } - } catch (_: SecurityException) { } - } - } - val onAccept = { onSave( RadioControlSettings( - enabled = enabled.value, - radioModel = radioModel.value, + enabled = enabled.value, + radioModel = radioModel.value, txRadioAddress = txAddress.value, rxRadioAddress = if (isSingleRadio) "" else rxAddress.value, - txRadioName = txName.value, - rxRadioName = if (isSingleRadio) "" else rxName.value, - baudRate = baudRate.intValue, - splitMode = splitMode.value + txRadioName = txName.value, + rxRadioName = if (isSingleRadio) "" else rxName.value, + baudRate = baudRate.intValue, + splitMode = splitMode.value ) ) onDismiss() } - SharedDialog( - title = stringResource(R.string.rc_settings_title), + ConfirmDialog( + title = stringResource(R.string.rc_settings_title), onCancel = onDismiss, onAccept = onAccept ) { Column(modifier = Modifier.padding(horizontal = padding)) { - // Enable switch Row( horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.rc_enable_switch)) Switch(checked = enabled.value, onCheckedChange = { enabled.value = it }) } Spacer(modifier = Modifier.height(6.dp)) - // Radio model — FlowRow so chips wrap on small screens Text( - text = stringResource(R.string.rc_radio_model), + text = stringResource(R.string.rc_radio_model), fontWeight = FontWeight.Medium, - color = androidx.compose.material3.MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary ) - FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxWidth() + ) { RadioControlSettings.SUPPORTED_RADIOS.forEach { model -> FilterChip( selected = radioModel.value == model, - onClick = { radioModel.value = model }, - label = { Text(model, fontSize = 12.sp) }, - enabled = enabled.value + onClick = { radioModel.value = model }, + label = { + Text( + text = compactRadioModelLabel(model), + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + enabled = enabled.value, + modifier = Modifier.weight(1f) ) } } Spacer(modifier = Modifier.height(6.dp)) - // IC-705 split-mode toggle (only shown for IC-705) - if (isIcom) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Split mode (single radio)", fontWeight = FontWeight.Medium) - Text( - text = "Use VFO-A/B split on one IC-705 instead of two radios", - fontSize = 12.sp, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = splitMode.value, - onCheckedChange = { splitMode.value = it }, - enabled = enabled.value - ) - } - Spacer(modifier = Modifier.height(6.dp)) + val isSplitModeAvailable = enabled.value && isIcom + val splitModeLabelColor = if (isSplitModeAvailable) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f) } - - // TX Radio (always shown; in split mode this is the single IC-705) - val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)" - Text(txLabel, fontWeight = FontWeight.Medium) - if (txAddress.value.isNotBlank()) { - Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp) - } - CardButton( - onClick = { selectingFor.value = "tx" }, - text = if (isSingleRadio) "Select Device" else "Select TX Device", + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() - ) + ) { + Text( + text = "Split mode (IC-705 only)", + fontWeight = FontWeight.Medium, + color = splitModeLabelColor + ) + Switch( + checked = splitMode.value, + onCheckedChange = { splitMode.value = it }, + enabled = isSplitModeAvailable + ) + } Spacer(modifier = Modifier.height(6.dp)) - // RX Radio (hidden in split mode — the same radio handles both) - if (!isSingleRadio) { - Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium) - if (rxAddress.value.isNotBlank()) { - Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp) - } + Text("Radio devices", fontWeight = FontWeight.Medium) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { CardButton( - onClick = { selectingFor.value = "rx" }, - text = "Select RX Device", - modifier = Modifier.fillMaxWidth() + onClick = { selectingFor.value = "tx" }, + text = if (isSingleRadio) "Select Radio" else "Select TX", + modifier = Modifier.weight(1f) + ) + CardButton( + onClick = { if (!isSingleRadio) selectingFor.value = "rx" }, + text = if (isSingleRadio) "RX = TX" else "Select RX", + modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.height(6.dp)) } + if (txAddress.value.isNotBlank()) { + Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp) + } + if (isSingleRadio && txAddress.value.isNotBlank()) { + Text("RX: same as TX", fontSize = 13.sp) + } else if (rxAddress.value.isNotBlank()) { + Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp) + } + Spacer(modifier = Modifier.height(6.dp)) - // Paired device picker (inline, shown while selecting) if (selectingFor.value.isNotBlank()) { Text( - text = "Paired Bluetooth Devices:", + text = "Paired Bluetooth Devices:", fontWeight = FontWeight.Medium, - color = androidx.compose.material3.MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(2.dp)) - if (pairedDevices.isEmpty()) { + if (pairedBluetoothDevices.isEmpty()) { Text( "No paired devices found. Pair your BT adapter in Android Bluetooth settings first.", fontSize = 13.sp ) } else { - pairedDevices.forEach { (name, address) -> + pairedBluetoothDevices.forEach { (name, address) -> androidx.compose.material3.Surface( modifier = Modifier .fillMaxWidth() .clickable { if (selectingFor.value == "tx") { txAddress.value = address - txName.value = name + txName.value = name + if (isSingleRadio) { + rxAddress.value = address + rxName.value = name + } } else { rxAddress.value = address - rxName.value = name + rxName.value = name } selectingFor.value = "" } .padding(vertical = 4.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text(name, modifier = Modifier.weight(1f)) @@ -689,15 +781,26 @@ fun RadioControlDialog( Spacer(modifier = Modifier.height(6.dp)) } - // Baud rate — FlowRow so all chips fit on narrow screens Text("Baud Rate:", fontWeight = FontWeight.Medium) - FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(2.dp), + maxItemsInEachRow = 6, + modifier = Modifier.fillMaxWidth() + ) { baudRates.forEach { rate -> FilterChip( selected = rate == baudRate.intValue, - onClick = { baudRate.intValue = rate }, - label = { Text(rate.toString(), fontSize = 12.sp) }, - enabled = enabled.value + onClick = { baudRate.intValue = rate }, + label = { + Text( + text = baudLabel(rate), + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + enabled = enabled.value, + modifier = Modifier.weight(1f) ) } } @@ -705,3 +808,20 @@ fun RadioControlDialog( } } } + +private fun compactRadioModelLabel(model: String): String = when (model) { + RadioControlSettings.MODEL_YAESU_FT817 -> "FT-817/818" + RadioControlSettings.MODEL_YAESU_FT857 -> "FT-857/897" + RadioControlSettings.MODEL_ICOM_IC705 -> "IC-705" + else -> model +} + +private fun baudLabel(rate: Int): String = when (rate) { + 4800 -> "4k" + 9600 -> "9k" + 19200 -> "19k" + 38400 -> "38k" + 57600 -> "57k" + 115200 -> "115k" + else -> rate.toString() +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 7a4bae96..e9aef985 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -27,6 +27,7 @@ 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -34,6 +35,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.layout.width @@ -54,6 +56,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text @@ -94,6 +97,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider @@ -104,6 +108,7 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.TopBar +import com.rtbishop.look4sat.core.presentation.WhatsNewDialog import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.domain.navigation.MenuLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout @@ -233,18 +238,15 @@ private fun SettingsScreen( onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false }, onDismiss = { dialogs.dataSources = false }, - onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl -> - val current = uiState.dataSourcesSettings - val newSettings = current.copy( - useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE, - tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl, - useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers, - transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl + onSave = { useTle, useTx, tleUrl, txUrl -> + val newSettings = DataSourcesSettings( + useCustomTLE = useTle, + useCustomTransceivers = useTx, + tleUrl = tleUrl, + transceiversUrl = txUrl ) - if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings)) - if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) { - onAction(SettingsAction.UpdateFromWeb) - } + if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings)) + onAction(SettingsAction.UpdateFromWeb) } ) } @@ -252,14 +254,15 @@ private fun SettingsScreen( NetworkOutputDialog( initialSettings = uiState.rcSettings, onDismiss = { dialogs.network = false }, - onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt -> + onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz -> onAction( SettingsAction.UpdateRC( uiState.rcSettings.copy( rotatorState = rotState, rotatorAddress = rotAddr, rotatorPort = rotPort, rotatorFormat = rotFmt, frequencyState = freqState, frequencyAddress = freqAddr, - frequencyPort = freqPort, frequencyFormat = freqFmt + frequencyPort = freqPort, frequencyFormat = freqFmt, + frequencyOffsetHz = freqOffsetHz ) ) ) @@ -286,10 +289,14 @@ private fun SettingsScreen( if (dialogs.radioControl) { RadioControlDialog( initialSettings = uiState.radioControlSettings, + pairedBluetoothDevices = uiState.pairedBluetoothDevices, onDismiss = { dialogs.radioControl = false }, onSave = { onAction(SettingsAction.UpdateRadioControl(it)) } ) } + if (dialogs.whatsNew) { + WhatsNewDialog(onDismiss = { dialogs.whatsNew = false }) + } // URLs for top bar val uriHandler = LocalUriHandler.current @@ -310,11 +317,11 @@ private fun SettingsScreen( if (isVerticalLayout) { TopBar { TopCard( - onClick = { safeOpenUri(appUrl) }, + onClick = { dialogs.whatsNew = true }, version = uiState.appVersionName, modifier = Modifier.weight(1f) ) - PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) + PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) } TopBar { Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -336,9 +343,9 @@ private fun SettingsScreen( } } else { TopBar { - PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) + PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) TopCard( - onClick = { safeOpenUri(appUrl) }, + onClick = { dialogs.whatsNew = true }, version = uiState.appVersionName, modifier = Modifier.weight(1f) ) @@ -410,6 +417,7 @@ private fun SettingsScreen( ) } item { CardCredits() } + } } } @@ -590,37 +598,57 @@ private fun OtherCardPreview() = MainTheme { shouldSeeWarning = false, shouldSeeWhatsNew = false ) - OtherCard(settings = values) {} + OtherCard(settings = values, onAction = {}) } @Composable -private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) { - ElevatedCard( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { +private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) { + ElevatedCard(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + Text( text = stringResource(id = R.string.prefs_other_title), color = MaterialTheme.colorScheme.primary ) - SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) { - onAction(SettingsAction.ToggleUtc(it)) + Spacer(modifier = Modifier.height(4.dp)) + // Display preferences: UTC clock + night filter + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) { + onAction(SettingsAction.ToggleUtc(it)) + } + SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { + onAction(SettingsAction.ToggleNightMode(it)) + } } + Spacer(modifier = Modifier.height(4.dp)) + // Radar behavior: sweep animation + sensor control + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) { + onAction(SettingsAction.ToggleSweep(it)) + } + SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) { + onAction(SettingsAction.ToggleSensor(it)) + } + } + Spacer(modifier = Modifier.height(4.dp)) + // Data management (full width) SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) { onAction(SettingsAction.ToggleUpdate(it)) } - SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) { - onAction(SettingsAction.ToggleSweep(it)) - } - SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) { - onAction(SettingsAction.ToggleSensor(it)) - } - SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { - onAction(SettingsAction.ToggleNightMode(it)) - } + Spacer(modifier = Modifier.height(4.dp)) + // Compass calibration sliders at the bottom + CompassOffsetRow( + labelResId = R.string.prefs_other_compass_offset, + value = settings.radarCompassOffset, + range = -180f..180f + ) { onAction(SettingsAction.SetRadarCompassOffset(it)) } + Spacer(modifier = Modifier.height(4.dp)) + CompassOffsetRow( + labelResId = R.string.prefs_other_compass_offset_elev, + value = settings.radarCompassOffsetElev, + range = -90f..90f + ) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) } + Spacer(modifier = Modifier.height(4.dp)) } } } @@ -996,13 +1024,52 @@ private fun DragOrderList( } @Composable -private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) { +private fun CompassOffsetRow( + labelResId: Int, + value: Float, + range: ClosedFloatingPointRange, + onValueChange: (Float) -> Unit +) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { Text(text = stringResource(id = labelResId)) + Text(text = "${value.toInt()}°") + } + Spacer(modifier = Modifier.height(4.dp)) + Slider( + value = value, + onValueChange = onValueChange, + valueRange = range + ) +} + +@Composable +private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) { + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = labelResId)) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(id = labelResId), + modifier = Modifier.weight(1f) + ) Switch(checked = checked, onCheckedChange = onCheckedChange) } } @@ -1040,6 +1107,7 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() } private fun CardCredits(modifier: Modifier = Modifier) { ElevatedCard( modifier = modifier.fillMaxWidth() + ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -1080,7 +1148,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version: .clickable { onClick() }) { Spacer(Modifier) Icon( - painter = painterResource(id = R.drawable.ic_satellites), + painter = painterResource(id = R.drawable.ic_sputnik), contentDescription = null, tint = MaterialTheme.colorScheme.primary ) @@ -1130,6 +1198,7 @@ private class DialogVisibility { var network by mutableStateOf(false) var bluetooth by mutableStateOf(false) var radioControl by mutableStateOf(false) + var whatsNew by mutableStateOf(false) } @Composable @@ -1137,12 +1206,12 @@ private fun rememberDialogVisibility(): DialogVisibility { return rememberSaveable(saver = run { androidx.compose.runtime.saveable.Saver( save = { - listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl) + listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew) }, restore = { DialogVisibility().apply { position = it[0]; locator = it[1]; dataSources = it[2] - network = it[3]; bluetooth = it[4]; radioControl = it[5] + network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6] } } ) @@ -1211,7 +1280,7 @@ private fun rememberSettingsPermissions( onCustomSourcesPermissionGranted() } else { onCustomSourcesPermissionDenied() - sendAction(SettingsAction.ShowToast(networkError)) + sendAction(SettingsAction.ShowToast(satellitesImportError)) } } @@ -1233,11 +1302,9 @@ private fun rememberSettingsPermissions( } }, launchCustomSourcesPermission = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { - customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) - } else { - onCustomSourcesPermissionGranted() - } + val storagePerm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Manifest.permission.READ_MEDIA_IMAGES else Manifest.permission.READ_EXTERNAL_STORAGE + customSourcesRequest.launch(storagePerm) } ) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 4cd148ca..c2629bcf 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -41,7 +41,8 @@ data class SettingsState( val otherSettings: OtherSettings, val rcSettings: RCSettings, val radioControlSettings: RadioControlSettings, - val dataSourcesSettings: DataSourcesSettings + val dataSourcesSettings: DataSourcesSettings, + val pairedBluetoothDevices: List> = emptyList() ) sealed interface SettingsAction { @@ -80,6 +81,9 @@ sealed interface SettingsAction { data object UploadWavelogQueue : SettingsAction // WaveLog: update LoTW satellite list data object UpdateLotwSatellites : SettingsAction + data class SetRadarCompassOffset(val value: Float) : SettingsAction + data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction + // Remote control data class UpdateRC(val settings: RCSettings) : SettingsAction diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 03658f55..54315dbc 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue class SettingsViewModel( + private val container: IMainContainer, private val databaseRepo: IDatabaseRepo, private val settingsRepo: ISettingsRepo, private val showToast: IShowToast, @@ -55,7 +56,8 @@ class SettingsViewModel( otherSettings = settingsRepo.otherSettings.value, rcSettings = settingsRepo.rcSettings.value, radioControlSettings = settingsRepo.radioControlSettings.value, - dataSourcesSettings = settingsRepo.dataSourcesSettings.value + dataSourcesSettings = settingsRepo.dataSourcesSettings.value, + pairedBluetoothDevices = container.providePairedBluetoothDevices() ) ) @@ -140,6 +142,9 @@ class SettingsViewModel( SettingsAction.ResetMenuOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList(), subMenuOrder = emptyList()) } + is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) } + is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) } + // Remote control & data sources is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings) @@ -315,6 +320,7 @@ class SettingsViewModel( fun factory(container: IMainContainer) = viewModelFactory { initializer { SettingsViewModel( + container = container, databaseRepo = container.databaseRepo, settingsRepo = container.settingsRepo, showToast = container.provideShowToast(), diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index 9372c1f2..ed7d71d4 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -1,34 +1,25 @@ package com.rtbishop.look4sat.feature.status -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.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -42,26 +33,32 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.lifecycle.viewmodel.viewModelFactory import com.rtbishop.look4sat.core.domain.model.SatDay import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatStatus -import com.rtbishop.look4sat.core.domain.repository.IMainContainer +import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.feature.status.R as StatusR +import com.rtbishop.look4sat.core.presentation.layoutPadding import java.util.Calendar +/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */ +private val TILE_WIDTH: Dp = 64.dp + /** * Map AMSAT status text to Material3 colorScheme colors. * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. @@ -70,41 +67,30 @@ import java.util.Calendar private fun statusColorOf(statusText: String): Color { return when { statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> - MaterialTheme.colorScheme.tertiary // Active + MaterialTheme.colorScheme.tertiary statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> - MaterialTheme.colorScheme.tertiaryContainer // Telemetry + MaterialTheme.colorScheme.tertiaryContainer statusText.contains("Not Heard", ignoreCase = true) -> - Color(0xFFDC267F) // NotHeard pink (no semantic slot) + Color(0xFFDC267F) else -> - MaterialTheme.colorScheme.error // Conflict + MaterialTheme.colorScheme.error } } -private val NoReportGray = Color(0xFFC0C0C0) // Neutral state +@Composable +fun SatStatusDestination() { + val context = LocalContext.current + val container = (context.applicationContext as IContainerProvider).getMainContainer() + val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + SatStatusScreen(uiState) { viewModel.refresh() } +} @Composable -fun SatStatusScreen(container: IMainContainer) { - val viewModel: SatStatusViewModel = viewModel( - factory = viewModelFactory { - initializer { SatStatusViewModel(container) } - } - ) - val uiState by viewModel.uiState.collectAsStateWithLifecycle() +private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) { var selectedDay by remember { mutableStateOf?>(null) } - - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding() - .padding(horizontal = 8.dp) - ) { - // Top: legend + refresh - StatusHeader( - fetchedAtUtcMs = uiState.fetchedAtUtcMs, - isRefreshing = uiState.isRefreshing, - onRefresh = { viewModel.refresh() } - ) + Column(modifier = Modifier.fillMaxSize().layoutPadding()) { + StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) LegendRow() when { @@ -119,29 +105,16 @@ fun SatStatusScreen(container: IMainContainer) { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = stringResource(id = R.string.amsat_load_failed), - color = MaterialTheme.colorScheme.error - ) - TextButton(onClick = { viewModel.refresh() }) { - Text(text = stringResource(id = R.string.amsat_retry)) - } + Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error) + TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) } } } else -> { - // Header HeaderRow(statuses = uiState.statuses) HorizontalDivider(thickness = 1.dp) - // Row - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 16.dp) - ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { items(uiState.statuses, key = { it.name }) { status -> - StatusRow( - status = status, - onClickDay = { day -> selectedDay = status to day } - ) + StatusRow(status = status, onClickDay = { day -> selectedDay = status to day }) } } } @@ -149,56 +122,41 @@ fun SatStatusScreen(container: IMainContainer) { } selectedDay?.let { (status, day) -> - ReportDialog( - statusName = status.name, - day = day, - reports = uiState.reports, - onDismiss = { selectedDay = null } - ) + ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null }) } } /** Top: update time + refresh button (spinner while loading) */ @Composable -private fun StatusHeader( - fetchedAtUtcMs: Long, - isRefreshing: Boolean, - onRefresh: () -> Unit -) { - val infiniteTransition = rememberInfiniteTransition(label = "refresh") - val angle by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart), - label = "angle" - ) +private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Text( - text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title), - fontSize = 13.sp, + text = if (fetchedAtUtcMs > 0) + stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) + else + stringResource(id = R.string.amsat_title), + fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f) ) Box( modifier = Modifier - .size(34.dp) + .size(32.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) .clickable(onClick = onRefresh), contentAlignment = Alignment.Center ) { if (isRefreshing) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) } else { Image( - painter = painterResource(id = StatusR.drawable.ic_refresh), + painter = painterResource(id = R.drawable.ic_refresh), contentDescription = stringResource(id = R.string.amsat_refresh), - modifier = Modifier - .size(18.dp) - .rotate(angle), + modifier = Modifier.size(16.dp), colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) ) } @@ -206,177 +164,164 @@ private fun StatusHeader( } } -/** Legend (official four colors) */ +/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */ @Composable private fun LegendRow() { val legend = listOf( - stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary, - stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer, + stringResource(id = R.string.amsat_active) to Color(0xFF648FFF), + stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000), stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F), - stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error + stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100) ) - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + FlowRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { legend.forEach { (label, color) -> - Row(verticalAlignment = Alignment.CenterVertically) { + val alphaColor = color.copy(alpha = 0.25f) + Row( + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(alphaColor) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { Box( modifier = Modifier - .size(10.dp) - .clip(RoundedCornerShape(2.dp)) + .size(8.dp) + .clip(RoundedCornerShape(4.dp)) .background(color) ) - Spacer(modifier = Modifier.width(3.dp)) - Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = label, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } } -/** Header: satellite name + 6 day dates */ +/** Header: satellite name column + fixed-width date labels aligned to tiles. */ @Composable private fun HeaderRow(statuses: List) { val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList() Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(id = R.string.amsat_name), - fontSize = 11.sp, - fontWeight = FontWeight.Bold, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(2f).padding(start = 4.dp) + modifier = Modifier.weight(1f).padding(start = 4.dp) ) dates.forEach { date -> Text( text = date, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(0.8f) + modifier = Modifier.width(TILE_WIDTH) ) } } } -/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */ +/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */ @Composable private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { val noReportGray = 0xFFC0C0C0L Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = status.name, - fontSize = 11.sp, + fontSize = 14.sp, maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - modifier = Modifier.weight(2f).padding(start = 4.dp) + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(end = 4.dp) ) status.days.forEach { day -> - // A day can legitimately carry no slots: the parser skips cells it - // cannot find, so a page that ships fewer than 12 cells for a day - // yields an empty list. first() would crash the whole status screen, - // so fall back to a gray placeholder instead. - val slot = day.slots.firstOrNull { it.statusColor != noReportGray } - ?: day.slots.firstOrNull() - ?: SatSlot(statusColor = noReportGray, count = 0) + val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() DayCell( slot = slot, - modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp), + modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp), onClick = { onClickDay(day) } ) } } } -/** Day block: newest reported status among the day's 12 slots; gray when none */ +/** Day block: newest reported status among the day's 12 slots; gray when none. */ @Composable private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { val color = Color(slot.statusColor) Box( modifier = modifier - .height(24.dp) + .height(28.dp) .clip(RoundedCornerShape(4.dp)) .background(color) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { if (slot.count > 0) { - Text( - text = slot.count.toString(), - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) + Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White) } } } -/** Report detail dialog (3 levels: callsign/date/time/grid) */ +/** Report detail dialog (callsign / date / time / grid) */ @Composable -private fun ReportDialog( - statusName: String, - day: SatDay, - reports: Map, - onDismiss: () -> Unit -) { - val dayReports = day.slots.flatMap { it.reportIds } - .mapNotNull { reports[it] } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = "$statusName · ${day.dateLabel}") }, - text = { - if (dayReports.isEmpty()) { - Text(stringResource(id = R.string.amsat_no_reports)) - } else { - LazyColumn(modifier = Modifier.height(320.dp)) { - items(dayReports) { report -> - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(statusColorOf(report.statusText)) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = report.statusText, - fontSize = 13.sp, - fontWeight = FontWeight.Bold - ) - } - Text( - text = "${report.call} ${report.dateUtc} ${report.timeUtc}" + - if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant +private fun ReportDialog(statusName: String, day: SatDay, reports: Map, onDismiss: () -> Unit) { + val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } + InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) { + if (dayReports.isEmpty()) { + Text(stringResource(id = R.string.amsat_no_reports)) + Spacer(modifier = Modifier.height(8.dp)) + } else { + LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { + items(dayReports) { report -> + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(statusColorOf(report.statusText)) ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold) } - HorizontalDivider(thickness = 0.5.dp) + Text( + text = "${report.call} ${report.dateUtc} ${report.timeUtc}" + + if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + HorizontalDivider(thickness = 0.5.dp) } } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) } } - ) + } } +private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") private fun formatFetchedAt(utcMs: Long): String { val cal = Calendar.getInstance() cal.timeInMillis = utcMs - return "%02d-%02d %02d:%02d".format( - cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), - cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE) - ) + val day = cal.get(Calendar.DAY_OF_MONTH) + val month = MONTH_ABBR[cal.get(Calendar.MONTH)] + val year = cal.get(Calendar.YEAR) + val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0') + val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0') + return "$day$month $year - $hh:$mm:$ss" } diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt index dd0c4a57..49371011 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt @@ -2,10 +2,12 @@ package com.rtbishop.look4sat.feature.status import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatStatus +import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IMainContainer -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -21,7 +23,7 @@ data class SatStatusUiState( ) class SatStatusViewModel( - private val container: IMainContainer + private val amSatRepo: IAmSatRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) @@ -35,7 +37,7 @@ class SatStatusViewModel( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } try { - val page = container.amSatRepo.fetchStatus() + val page = amSatRepo.fetchStatus() if (page != null && page.statuses.isNotEmpty()) { _uiState.update { it.copy( @@ -51,8 +53,6 @@ class SatStatusViewModel( it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed") } } - } catch (exception: CancellationException) { - throw exception } catch (exception: Exception) { _uiState.update { it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed") @@ -66,7 +66,7 @@ class SatStatusViewModel( _uiState.update { it.copy(isRefreshing = true, error = null) } viewModelScope.launch { try { - val page = container.amSatRepo.fetchStatus() + val page = amSatRepo.fetchStatus() if (page != null && page.statuses.isNotEmpty()) { _uiState.update { it.copy( @@ -79,11 +79,17 @@ class SatStatusViewModel( } else { _uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") } } - } catch (exception: CancellationException) { - throw exception } catch (exception: Exception) { _uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") } } } } + + companion object { + fun factory(container: IMainContainer) = viewModelFactory { + initializer { + SatStatusViewModel(container.amSatRepo) + } + } + } } diff --git a/gradle.properties b/gradle.properties index 6308f0e2..c72e9bb5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,5 @@ org.gradle.caching=true org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.parallel=true +org.gradle.tooling.parallel=true org.gradle.workers.max=4 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a8002a4a..fb0ce11b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ -#Sat Jun 27 12:51:12 BST 2026 +#Tue Aug 11 14:16:53 CEST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME