From 2d3adfc6a644432cb4420236b588ae8578549462 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Fri, 31 Jul 2026 12:26:42 +0200 Subject: [PATCH 01/18] Added small tweaks to Sources, Components and strings --- .github/dependabot.yml | 4 + AGENTS.md | 97 +++--- .../core/data/injection/MainContainer.kt | 7 + .../core/data/repository/SelectionRepo.kt | 2 +- .../core/domain/repository/IMainContainer.kt | 1 + .../look4sat/core/domain/source/Sources.kt | 1 + .../look4sat/core/presentation/Components.kt | 3 +- .../src/main/res/values-es/strings.xml | 69 +++-- .../src/main/res/values-ru/strings.xml | 56 ++-- .../src/main/res/values-si/strings.xml | 71 +++-- .../src/main/res/values-tr/strings.xml | 52 ++-- .../src/main/res/values-uk/strings.xml | 65 ++-- .../src/main/res/values-zh/strings.xml | 49 +-- .../src/main/res/values/strings.xml | 52 ++-- .../look4sat/feature/passes/PassesScreen.kt | 2 + .../feature/radar/TransceiversPage.kt | 5 +- .../feature/satellites/SatellitesScreen.kt | 1 + .../feature/settings/SettingsDialog.kt | 285 ++++++++++-------- .../feature/settings/SettingsScreen.kt | 1 + .../feature/settings/SettingsState.kt | 3 +- .../feature/settings/SettingsViewModel.kt | 5 +- 21 files changed, 485 insertions(+), 346 deletions(-) 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/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/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 a45e3481..75d7f7df 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 @@ -135,6 +135,13 @@ class MainContainer(private val context: Context) : IMainContainer { return SensorsRepo(manager, displayManager) } + 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) {} + } + private fun provideDatabaseRepo(): IDatabaseRepo { val dbDispatcher = Dispatchers.Default val dataParser = DataParser(dbDispatcher) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt index 6836d264..757cccd9 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt @@ -62,7 +62,7 @@ class SelectionRepo( override fun getCurrentTypes() = currentTypes.value override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { - removeAt(0) + remove("All") } override suspend fun getEntriesFlow() = withContext(dispatcher) { 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 a46bbaeb..34e3e8fa 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 @@ -39,6 +39,7 @@ interface IMainContainer { fun provideRxRadioController(): IRadioController fun provideAudioCapture(): IAudioCapture fun provideSaveImage(): ISaveImage + fun providePairedBluetoothDevices(): List> } interface IContainerProvider { 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 17c91e34..4eb72da4 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 @@ -46,6 +46,7 @@ object Sources { "Classified" to "https://www.mmccants.org/tles/classfd.zip", "McCants" to "https://www.mmccants.org/tles/inttles.zip", "R4UAB" to "https://r4uab.ru/satonline.txt", + "ARISS" to "https://live.ariss.org/iss.txt", "Other" to "" // key for sats filter ) val transceiversDataUrls = mapOf( 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 cb0a3b65..d95eaec3 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 @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -357,7 +358,7 @@ private fun DialogShell( onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, - shape = MaterialTheme.shapes.medium, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), scrimColor = Color.Black.copy(alpha = 0.64f) ) { Column( diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index 1d9bd834..bf6d324d 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -14,7 +14,7 @@ Tipo: %s - Seleccionar tipo de satélite + Seleccionar tipos Id - Nombre Vaciar Vaciar todo @@ -35,7 +35,7 @@ Ventana AOS Invertir ventana AOS DeepSpace (período >225min) - Tipo de modulación + Seleccionar modos Elevación: %.1f° Altitud: %.0f km @@ -96,11 +96,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 +110,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 + Mostrar hora UTC + Actualización automática + Animación de radar + Sensores para radar + Filtro nocturno rojo 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..85a42f47 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -14,7 +14,7 @@ Тип: %s - Выберите тип спутника + Выберите типы Id - Название Очистить Очистить @@ -34,7 +34,7 @@ Окно AOS Инвертировать окно AOS DeepSpace (период >225мин) - Выберите тип модуляции + Выберите режимы Элевация: %.1f° Высота: %d км @@ -95,11 +95,11 @@ QTH Неправильный локатор QTH Позиция успешно обновлена - Настройки местоположения - Введите широту наземной станции - Введите долготу наземной станции - Настройки локатора QTH - Введите позицию через локатор + Положение станции + Широта станции + Долгота станции + Локатор QTH + Позиция по локатору Данные спутников Спутников: %s @@ -109,16 +109,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 +140,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..0310dbb1 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -14,7 +14,7 @@ වර්ගය: %s - චන්ද්‍රිකා ආකාරය තෝරන්න + ආකාර තෝරන්න Id - නම පිරිසිදු ක° සියල්ල පිරිසිදු ක° @@ -35,7 +35,7 @@ AOS කවුළුව AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න DeepSpace (කාලය >225min) - මූර්ජන ආකාරය තෝරන්න + මූර්ජන තෝරන්න උත්තෝලනය: %.1f° උන්නතාශය: %d km @@ -96,11 +96,11 @@ QTH QTH locator වලංගු නොවේ පිහිටීම යාවත්කාලීනය සාර්ථකයි - පොළෙහි පිහිටීම් පසුතල - ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න - ඔබගේ පොළෙහි දේශාංශය සකසන්න - QTH locator පසුතල - ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න + පොළ පිහිටීම + පොළ අක්‍ෂාංශය + පොළ දේශාංශය + QTH locator + locator මඟින් පොළ පිහිටීම සකසන්න චන්ද්‍රිකා දත්ත චන්ද්‍රිකා: %s @@ -110,39 +110,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 සජීවිකරණය + Radar සඳහා සංවේදක භාවිතා කරන්න + රතු රාත්‍රී පෙරහන උස් උද්දීපනය අඩු < %1$d° diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 7e65fb7b..4a0fec46 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -15,7 +15,7 @@ Uydu türü: %s - Uydu türü seçin + Türleri seçin Id - Ad Temizle Tümünü temizle @@ -36,7 +36,7 @@ AOS aralığını tersine çevir DeepSpace (periyot >225dk) -- : -- : -- - Modulasyon türü seçin + Modları seçin %05d Yükseklik açısı: %.1f° DeepSpace @@ -127,11 +127,11 @@ QTH Geçersiz QTH konumlandırıcısı Konum başarıyla güncellendi - İstasyon konumu ayarları - Yer istasyonunuzun enlemini girin - Yer istasyonunuzun boylamını girin - QTH konumlandırıcı ayarları - İstasyon konumunu konumlandırıcı ile ayarlayın + İstasyon konumu + İstasyon enlemi + İstasyon boylamı + QTH konumlandırıcı + Konumu lokatörle ayarla Uydu verisi Uydular: %s @@ -141,28 +141,30 @@ Temizle Veriler başarıyla temizlendi Güncelleme başarıyla tamamlandı + Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin. + Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin. - Özel veri kaynakları - Özel TLE URL - Özel transceiver URL + Özel kaynaklar + TLE URL + Transceiver URL URL (HTTPS) - Veri aktarımı + Veri çıkışı Ağ Bluetooth CAT Radyo Kontrolü - CAT Radyo Kontrolü + CAT kontrolü Radyo Modeli - TX Radyo BT Adresi - RX Radyo BT Adresi + TX BT adresi + RX BT adresi TX Radyo Adı RX Radyo Adı - CAT Kontrolünü Etkinleştir + CAT\'i etkinleştir - Ağ veri çıkışı + Ağ çıkışı Döndürme çıkışını etkinleştir IP:Port Biçim @@ -170,22 +172,22 @@ IP:Port Biçim - Bluetooth veri çıkışı + Bluetooth çıkışı Döndürme çıkışını etkinleştir Cihaz kimliği - Veri biçimi + Biçim Frekans çıkışını etkinleştir Cihaz kimliği - Veri biçimi + Biçim Bluetooth izninizi kontrol edin Ağ izninizi kontrol edin - Diğer ayarlar - Geçiş saatini UTC olarak göster - Otomatik veri güncellemeyi etkinleştir - Radar taramasını etkinleştir - Radar görünümünü döndürmek için sensörleri kullan - Kırmızı gece filtresini etkinleştir + Diğer + UTC saatini göster + Veriyi otomatik güncelle + Radar tarama animasyonu + Radar için sensör kullan + Kırmızı gece filtresi Elevasyon vurgusu Düşük < %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..cc6fb62c 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -14,7 +14,7 @@ Тип: %s - Виберіть тип супутника + Виберіть типи Id - Назва Очистити Очистити всі @@ -35,7 +35,7 @@ Вікно AOS Інвертувати вікно AOS DeepSpace (період >225хв) - Виберіть тип модуляції + Виберіть режими Піднес.: %.1f° Висота: %.0f км @@ -96,11 +96,11 @@ QTH Некоректний локатор QTH Позицію успішно оновлено - Налаштування місцезнаходження станції - Широта місцезнаходження станції - Довгота місцезнаходження станції - Налаштування QTH локатора - Задати позицію за QTH локатором + Позиція станції + Широта станції + Довгота станції + Локатор QTH + Позиція за локатором Супутникові дані Супутників: %s @@ -110,39 +110,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 50c0f6f5..d97b35aa 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -14,7 +14,7 @@ 类型:%s - 选择卫星类型 + 选择类型 Id - 名称 清空 全部清空 @@ -30,7 +30,7 @@ AOS 时间段 反选 AOS 时间段 DeepSpace(周期 >225分钟) - 选择调制类型 + 选择模式 仰角:%.1f° 高度:%d km 请确保您的过滤器设置正确,并且已选择卫星 @@ -88,11 +88,11 @@ QTH 网格 无效的 QTH 网格 位置更新成功 - 站位设置 - 输入站位的纬度 - 输入站位的经度 - QTH 网格 - 使用梅登黑德网格设置站位 + 站位 + 站位纬度 + 站位经度 + QTH 定位 + 通过 QTH 设置站位 卫星数据更新 卫星:%s @@ -102,16 +102,29 @@ 清空 数据清空成功 更新成功 + 未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。 + 未导入收发器。请选择有效的 SatNOGS (.json) 文件。 - 自定义数据源 - 自定义TLE URL - 自定义收发器URL + 自定义源 + TLE URL + 收发器 URL + URL (HTTPS) 数据输出 网络 蓝牙 + CAT - 网络数据输出 + 电台控制 + CAT 控制 + 电台型号 + TX 电台蓝牙地址 + RX 电台蓝牙地址 + TX 电台名称 + RX 电台名称 + 启用 CAT + + 网络输出 启用方位俯仰角输出 IP:端口 数据格式 @@ -119,7 +132,7 @@ IP:端口 数据格式 - 蓝牙数据输出 + 蓝牙输出 启用方位俯仰角输出 蓝牙设备 ID 数据格式 @@ -129,12 +142,12 @@ 请检查蓝牙权限 请检查网络权限 - 其他设置 - 以 UTC 时间显示 - 启用卫星数据自动更新 - 启用雷达扫描动画 - 使用传感器旋转雷达视图 - 启用红色夜间模式 + 其他 + UTC 时间 + 自动更新数据 + 雷达扫描动画 + 传感器控制雷达 + 红色夜间模式 仰角高亮 低 < %1$d° diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 530545e5..380a7f71 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -15,7 +15,7 @@ Type: %s - Select satellite type + Select types Id - Name Clear Clear all @@ -36,7 +36,7 @@ Invert AOS window DeepSpace (period >225min) -- : -- : -- - Select modulation type + Select modes %05d Elevation: %.1f° DeepSpace @@ -124,11 +124,11 @@ QTH Invalid QTH locator Position updated successfully - Station position settings - Set your ground station\'s latitude - Set your ground station\'s longitude - QTH locator settings - Set station\'s position using locator + Station position + Station latitude + Station longitude + QTH locator + Set position via locator Satellite data Satellites: %s @@ -141,9 +141,9 @@ No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file. No transceivers imported. Select a valid SatNOGS (.json) file. - Custom data sources - Custom TLE URL - Custom transceivers URL + Custom sources + TLE URL + Transceivers URL URL (HTTPS) Data output @@ -153,15 +153,15 @@ Radio Control - CAT Radio Control + CAT control Radio Model - TX Radio BT Address - RX Radio BT Address + TX BT address + RX BT address TX Radio Name RX Radio Name - Enable CAT Control + Enable CAT - Network data output + Network output Enable rotation output IP:Port Format @@ -169,22 +169,22 @@ IP:Port Format - Bluetooth data output + Bluetooth output Enable rotation output - Device id - Data format + Device ID + Format Enable frequency output - Device id - Data format + Device ID + Format Check your bluetooth permission Check your network permission - Other settings - Show pass time in UTC - Enable automatic data update - Enable radar sweep animation - Use sensors to rotate radar view - Enable red night mode filter + Other + Show UTC time + Auto-update data + Radar sweep animation + Use sensors for radar + Red night filter Elevation highlight Low < %1$d° 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 6b9752e2..10a75a46 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 @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -147,6 +148,7 @@ private fun PassesScreen( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = padding) ) + Spacer(modifier = Modifier.height(0.dp)) } } ScreenColumn( 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 41bab435..c47f20e1 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 @@ -369,10 +369,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)) }, 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 78e6bb5a..c487e3b4 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 @@ -108,6 +108,7 @@ private fun SatellitesScreen( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = padding) ) + Spacer(modifier = Modifier.height(0.dp)) } } 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 c0897b4d..42ca9169 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,12 +17,9 @@ */ package com.rtbishop.look4sat.feature.settings -import android.bluetooth.BluetoothManager -import android.content.Context 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.Spacer @@ -30,18 +27,19 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -66,18 +64,27 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double, 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), - ) + 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)) } } @@ -100,12 +107,18 @@ 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) { + SharedDialog( + title = stringResource(R.string.prefs_locator_title), + onCancel = dismiss, + onAccept = onAccept + ) { OutlinedTextField( value = locator.value, onValueChange = { locator.value = it }, label = { Text(text = stringResource(id = R.string.prefs_locator_text)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalSpacing.current.large), ) Spacer(modifier = Modifier.height(0.dp)) } @@ -147,7 +160,12 @@ fun DataSourcesDialog( val urlTle = rememberSaveable { mutableStateOf(tleUrl) } val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) } val onAccept = { - onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value) + onSave( + isEnabledCustomTle.value, + isEnabledCustomTransceivers.value, + urlTle.value, + urlTransceivers.value + ) onDismiss() } val onCancel = { onDismiss() } @@ -377,11 +395,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, @@ -470,23 +492,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 @@ -496,157 +517,157 @@ fun RadioControlDialog( if (!isIcom && splitMode.value) splitMode.value = false 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 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), + 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)) @@ -658,15 +679,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) ) } } @@ -674,3 +706,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 e4c9e2b0..9170aa04 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 @@ -190,6 +190,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> if (dialogs.radioControl) { RadioControlDialog( initialSettings = uiState.radioControlSettings, + pairedBluetoothDevices = uiState.pairedBluetoothDevices, onDismiss = { dialogs.radioControl = false }, onSave = { onAction(SettingsAction.UpdateRadioControl(it)) } ) 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 928cf60b..d5c66f5e 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 { 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 f43e91f6..33a5a5b1 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 @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class SettingsViewModel( + private val container: IMainContainer, private val databaseRepo: IDatabaseRepo, private val settingsRepo: ISettingsRepo, private val showToast: IShowToast @@ -47,7 +48,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() ) ) @@ -204,6 +206,7 @@ class SettingsViewModel( fun factory(container: IMainContainer) = viewModelFactory { initializer { SettingsViewModel( + container = container, databaseRepo = container.databaseRepo, settingsRepo = container.settingsRepo, showToast = container.provideShowToast() From f9806d7f7f2ad386b1dfac049d83845dcb6d9d47 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Fri, 31 Jul 2026 16:04:14 +0200 Subject: [PATCH 02/18] Replaced the types selection dialog with modes selection --- .../core/data/database/Look4SatDao.kt | 7 +- .../core/data/repository/DatabaseRepo.kt | 8 +- .../core/data/repository/SatelliteRepo.kt | 2 +- .../core/data/repository/SelectionRepo.kt | 30 +-- .../core/data/repository/SettingsRepo.kt | 48 ++--- .../core/data/repository/DatabaseRepoTest.kt | 15 +- .../core/data/repository/SelectionRepoTest.kt | 173 ++++++++++++++++++ .../look4sat/core/domain/model/Settings.kt | 3 +- .../core/domain/repository/ISelectionRepo.kt | 6 +- .../core/domain/repository/ISettingsRepo.kt | 6 +- .../look4sat/core/domain/source/Sources.kt | 32 +--- .../src/main/res/values-es/strings.xml | 10 +- .../src/main/res/values-ru/strings.xml | 10 +- .../src/main/res/values-si/strings.xml | 10 +- .../src/main/res/values-tr/strings.xml | 10 +- .../src/main/res/values-uk/strings.xml | 10 +- .../src/main/res/values-zh/strings.xml | 10 +- .../src/main/res/values/strings.xml | 10 +- .../look4sat/feature/passes/PassesDialog.kt | 11 +- .../feature/passes/PassesViewModel.kt | 49 ++--- .../feature/satellites/SatellitesDialog.kt | 18 +- .../feature/satellites/SatellitesScreen.kt | 79 ++++++-- .../feature/satellites/SatellitesState.kt | 8 +- .../feature/satellites/SatellitesViewModel.kt | 20 +- 24 files changed, 393 insertions(+), 192 deletions(-) create mode 100644 core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt 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/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index 8decc7a6..0c404ad7 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -47,7 +47,6 @@ class DatabaseRepo( remoteSource.getFileStream(uri)?.let { stream -> val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream)) localSource.insertEntries(entries) - settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum }) importedCount = entries.size } setUpdateSuccessful(System.currentTimeMillis()) @@ -78,12 +77,9 @@ class DatabaseRepo( // launch all network requests concurrently val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } - // parse fetched data concurrently and associate with types + // parse fetched data concurrently val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> - val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType - stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries -> - settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum }) - } + stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() } val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) -> stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() 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 6c9c0c01..088ca67d 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 @@ -73,7 +73,7 @@ class SatelliteRepo( aosStartMinute = settings.aosStartMinute, aosEndMinute = settings.aosEndMinute, invertAosTimeWindow = settings.invertAosTimeWindow, - modes = settings.selectedModes + modes = settingsRepo.selectedSatModes.value ) } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt index 757cccd9..b29e3df0 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt @@ -38,16 +38,16 @@ class SelectionRepo( ) : ISelectionRepo { private val currentItems = MutableStateFlow>(emptyList()) - private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value) + private val currentModes = MutableStateFlow(settingsRepo.selectedSatModes.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()) { + private val itemsWithModes = currentModes.flatMapLatest { modes: List -> + val catnumSet: Set? = if (modes.isEmpty()) { null // null = no filtering } else { - val ids = settingsRepo.getSatelliteTypesIds(types) + val ids = localSource.getIdsWithModes(modes) 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() = currentModes.value - override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { - remove("All") - } + override fun getModesList() = Sources.satelliteModes override suspend fun getEntriesFlow() = withContext(dispatcher) { val selectedIds = settingsRepo.selectedIds.value.toHashSet() @@ -73,9 +77,9 @@ class SelectionRepo( return@withContext itemsWithQuery } - override suspend fun setTypes(types: List) { - currentTypes.value = types - settingsRepo.setSelectedTypes(types) + override suspend fun setModes(modes: List) { + currentModes.value = modes + 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 ccc34102..fb8f863c 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 @@ -68,8 +68,7 @@ class SettingsRepo( private val keyFrequencyPort = "frequencyPort" private val keyFrequencyFormat = "frequencyFormat" 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" @@ -95,9 +94,9 @@ class SettingsRepo( //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) @@ -105,10 +104,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 { @@ -117,10 +116,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 @@ -135,7 +134,6 @@ class SettingsRepo( putInt(keyFilterAosStartMinute, settings.aosStartMinute) putInt(keyFilterAosEndMinute, settings.aosEndMinute) putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow) - putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma)) _passesSettings.value = settings } @@ -146,16 +144,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 @@ -235,25 +230,6 @@ class SettingsRepo( private val _databaseState = MutableStateFlow(getDatabaseState()) override val databaseState: StateFlow = _databaseState - override fun getSatelliteTypesIds(types: List): List { - val idsSet = mutableSetOf() - types.forEach { type -> - val typeString = preferences.getString("type$type", null) - val typeIds = if (typeString.isNullOrBlank()) { - emptyList() - } else { - typeString.split(separatorComma).map { it.toInt() } - } - idsSet.addAll(typeIds) - } - return idsSet.toList() - } - - override fun setSatelliteTypeIds(type: String, ids: List) { - if (type == "All") return - val typesString = ids.joinToString(separatorComma) - preferences.edit { putString("type$type", typesString) } - } override fun updateDatabaseState(state: DatabaseState) = preferences.edit { putInt(keyNumberOfSatellites, state.numberOfSatellites) 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 a14ede56..a57c0ac8 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 @@ -62,7 +62,6 @@ class DatabaseRepoTest { assertEquals(1, localSource.insertedEntries.size) assertEquals(25544, localSource.insertedEntries.first().catnum) - assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"]) assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0) } @@ -102,7 +101,6 @@ class DatabaseRepoTest { repository.updateFromRemote() assertTrue(localSource.insertedEntries.any { it.catnum == 25544 }) - assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"]) } private fun validCsvStream(): InputStream = """ @@ -165,10 +163,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)) @@ -189,11 +187,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) ) - val satelliteTypeIdsByType = mutableMapOf>() - override fun setSelectedIds(ids: List) = Unit - override fun setSelectedTypes(types: List) = Unit + override fun setSelectedSatModes(modes: List) = Unit override fun setPassesSettings(settings: PassesSettings) = Unit @@ -203,11 +199,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override fun setStationPosition(locator: String): Boolean = true - override fun getSatelliteTypesIds(types: List): List = emptyList() - - override fun setSatelliteTypeIds(type: String, ids: List) { - satelliteTypeIdsByType[type] = ids - } override fun updateDatabaseState(state: DatabaseState) { databaseState.value = state 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..9a240b46 --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt @@ -0,0 +1,173 @@ +/* + * 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, "", "", "", 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 fun setStationPosition(): Boolean = true + + override fun setStationPosition(locator: String): Boolean = true + + 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 + } +} + + 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 ef830eb4..1647073b 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( 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 23b726e2..96d0283d 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 @@ -51,8 +51,6 @@ interface ISettingsRepo { //region # Database update settings val databaseState: StateFlow - fun getSatelliteTypesIds(types: List): List - fun setSatelliteTypeIds(type: String, ids: List) fun updateDatabaseState(state: DatabaseState) //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 4eb72da4..52dc76f1 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 @@ -20,36 +20,24 @@ package com.rtbishop.look4sat.core.domain.source object Sources { val satelliteDataUrls = mapOf( "All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", - "Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv", - "Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv", - "Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv", - "Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv", - "Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv", - "Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv", - "Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv", - "GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv", - "Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv", - "Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv", - "Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv", - "New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv", - "OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv", - "Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv", - "Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv", - "SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv", - "Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv", - "Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv", - "Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv", - "Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv", - "Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv", - "X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv", "Amsat" to "https://amsat.org/tle/current/nasabare.txt", "Classified" to "https://www.mmccants.org/tles/classfd.zip", "McCants" to "https://www.mmccants.org/tles/inttles.zip", "R4UAB" to "https://r4uab.ru/satonline.txt", + "SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le", "ARISS" to "https://live.ariss.org/iss.txt", "Other" to "" // key for sats filter ) val transceiversDataUrls = mapOf( "SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active" ) + val satelliteModes = listOf( + "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", + "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" + ) } diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index bf6d324d..f2f980ff 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 tipos + 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 diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index 85a42f47..3641e54e 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Всегда старайтесь сузить список до тех, - которые вас интересуют, используя поиск и селектор типов. + которые вас интересуют, используя поиск и выбор режимов. Фильтровать пролеты diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index 0310dbb1..11482985 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සෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න. diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 4a0fec46..72997597 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -14,19 +14,23 @@ Ayarlar - Uydu türü: %s - Türleri seçin + Modlar: %s + Modları seçin Id - Ad Temizle Tümünü temizle Tümünü seç + Seçilenler + Kullanılabilir + Seçilenler (%1$d) + Kullanılabilir (%1$d) "Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun" Uyarı\! Bu uygulamada 9000\'den fazla uydu listelenmiştir. Hepsini aynı anda takip etmek anlamsızdır. \n\nListeyi yalnızca ilgilendiğiniz uydularla - daraltmak için arama ve tür seçiciyi kullanın. + daraltmak için arama ve mod seçiciyi kullanın. Geçişleri filtrele diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index cc6fb62c..bde698d7 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Завжди намагайтеся звузити список лише до тих, - які вас цікавлять, за допомогою пошуку та селектора типів. + які вас цікавлять, за допомогою пошуку та вибору режимів. Фільтр прольотів diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index d97b35aa..d5169543 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -13,15 +13,19 @@ 设置 - 类型:%s - 选择类型 + 模式:%s + 选择模式 Id - 名称 清空 全部清空 全选 + 已选择 + 可用 + 已选择(%1$d) + 可用(%1$d) 请确保您的搜索查询正确且数据库已更新 警告\! - 此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星 + 此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星 筛选过境 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 380a7f71..492aaa2f 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -14,19 +14,23 @@ Settings - Type: %s - Select types + Modes: %s + Select modes Id - Name Clear Clear all Select all + Selected + Available + Selected (%1$d) + Available (%1$d) "Make sure your search query is correct and the DB is updated" Warning\! There are over 9000 satellites listed in this app. It makes no sense to track them all at the same time. \n\nAlways try to narrow down the list to only the ones - you\'re interested in via search and types selector. + you\'re interested in via search and the modes selector. Filter passes 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..4e1aa773 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 @@ -62,16 +62,9 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.ElevationHighColor import com.rtbishop.look4sat.core.presentation.ElevationLowColor import com.rtbishop.look4sat.core.presentation.elevationColor +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 @@ -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/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt index aaeda056..f8e6b230 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 @@ -60,7 +60,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 ) ) @@ -86,6 +86,11 @@ class PassesViewModel( } } } + viewModelScope.launch { + settingsRepo.selectedSatModes.collectLatest { modes -> + _uiState.update { it.copy(modes = modes) } + } + } // Main tick loop: reacts to new passes, then ticks every second viewModelScope.launch { satelliteRepo.passes.collectLatest { allPasses -> @@ -127,21 +132,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) } @@ -235,8 +228,7 @@ class PassesViewModel( aosStartMinute: Int, aosEndMinute: Int, invertAosTimeWindow: Boolean, - showDeepSpace: Boolean, - modes: List + showDeepSpace: Boolean ) = viewModelScope.launch { settingsRepo.setPassesSettings( PassesSettings( @@ -245,8 +237,7 @@ class PassesViewModel( minElevation, aosStartMinute, aosEndMinute, - invertAosTimeWindow, - modes + invertAosTimeWindow ) ) settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) } @@ -259,10 +250,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, @@ -274,6 +265,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( @@ -283,7 +288,7 @@ class PassesViewModel( aosStartMinute = settings.aosStartMinute, aosEndMinute = settings.aosEndMinute, invertAosTimeWindow = settings.invertAosTimeWindow, - modes = settings.selectedModes + modes = settingsRepo.selectedSatModes.value ) } 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..2147f9f3 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 @@ -45,18 +45,18 @@ 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) { @@ -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 c487e3b4..33441d7c 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 @@ -87,11 +87,11 @@ 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) { @@ -123,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( @@ -157,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( @@ -241,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), @@ -252,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 @@ -323,9 +323,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..72bf17fa 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) } } @@ -68,8 +68,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 +86,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) } } From 905995ab442425366c530cd994a8f4dede500a63 Mon Sep 17 00:00:00 2001 From: Lukas <77849373+qwqtoday@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:08:44 +0800 Subject: [PATCH 03/18] Added configurable Radar offset to the sensors output (#230) --- .../core/data/repository/SettingsRepo.kt | 8 +++- .../look4sat/core/domain/model/Settings.kt | 4 +- .../src/main/res/values/strings.xml | 2 + .../look4sat/feature/radar/RadarScreen.kt | 1 + .../look4sat/feature/radar/RadarState.kt | 1 + .../look4sat/feature/radar/RadarView.kt | 5 ++- .../look4sat/feature/radar/RadarViewModel.kt | 10 ++++- .../feature/settings/SettingsScreen.kt | 37 ++++++++++++++++++- .../feature/settings/SettingsState.kt | 2 + .../feature/settings/SettingsViewModel.kt | 2 + gradle.properties | 3 ++ 11 files changed, 68 insertions(+), 7 deletions(-) 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 fb8f863c..283142ac 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 @@ -86,6 +86,8 @@ class SettingsRepo( private val keySstvMode = "sstvMode" private val keyLowElevation = "lowElevation" private val keyHighElevation = "highElevation" + private val keyRadarCompassOffset = "radarCompassOffset" + private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" private val keyUseCustomTle = "useCustomTle" private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyTleUrl = "tleUrl" @@ -332,6 +334,8 @@ class SettingsRepo( putString(keySstvMode, new.sstvMode) putLong(keyLowElevation, new.lowElevation.toRawBits()) putLong(keyHighElevation, new.highElevation.toRawBits()) + putFloat(keyRadarCompassOffset, new.radarCompassOffset) + putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev) } new } @@ -348,7 +352,9 @@ class SettingsRepo( shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true), sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())), - highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())) + highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())), + radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f), + radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f) ) //endregion 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 1647073b..281e0c86 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 @@ -61,7 +61,9 @@ data class OtherSettings( val shouldSeeWhatsNew: Boolean, val sstvMode: String = "Auto", val lowElevation: Double = 15.0, - val highElevation: Double = 45.0 + val highElevation: Double = 45.0, + val radarCompassOffset: Float = 0f, + val radarCompassOffsetElev: Float = 0f ) data class DataSourcesSettings( diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 492aaa2f..d571a14b 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -189,6 +189,8 @@ Radar sweep animation Use sensors for radar Red night filter + Radar compass offset + Radar compass offset (elev) Elevation highlight Low < %1$d° 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 c3d753a2..aa164b32 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 @@ -225,6 +225,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { 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 bb5a0329..78aaadd9 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(), 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 bedf5a7d..07f4b0c1 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 @@ -74,6 +74,7 @@ fun RadarViewCompose( azimElev: Pair, shouldShowSweep: Boolean, shouldUseCompass: Boolean, + shouldFlipRadar: Boolean, modifier: Modifier = Modifier, sunPosition: CelestialComputer.SunPosition? = null, moonPosition: CelestialComputer.MoonPosition? = null, @@ -125,7 +126,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 c013022f..83d214fb 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,6 +67,8 @@ class RadarViewModel( private val stationPos = settingsRepo.stationPosition.value private val 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 @@ -99,7 +101,8 @@ class RadarViewModel( 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) } } } @@ -108,11 +111,14 @@ class RadarViewModel( private fun collectSettingsChanges() { viewModelScope.launch { settingsRepo.otherSettings.collectLatest { 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 ) } } 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 9170aa04..a32393ac 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 @@ -38,6 +38,7 @@ import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -487,7 +488,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni ElevatedCard( modifier = Modifier .fillMaxWidth() - .height(268.dp) + .height(404.dp) ) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Text( @@ -506,6 +507,16 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) { onAction(SettingsAction.ToggleSensor(it)) } + CompassOffsetRow( + labelResId = R.string.prefs_other_compass_offset, + value = settings.radarCompassOffset, + range = -180f..180f + ) { onAction(SettingsAction.SetRadarCompassOffset(it)) } + CompassOffsetRow( + labelResId = R.string.prefs_other_compass_offset_elev, + value = settings.radarCompassOffsetElev, + range = -90f..90f + ) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) } SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { onAction(SettingsAction.ToggleNightMode(it)) } @@ -513,6 +524,28 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni } } +@Composable +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()}°") + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = range + ) +} + @Composable private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( @@ -559,7 +592,7 @@ private fun CardCredits(modifier: Modifier = Modifier) { ElevatedCard( modifier = modifier .fillMaxWidth() - .height(268.dp) + .height(404.dp) ) { Column( verticalArrangement = Arrangement.SpaceBetween, 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 d5c66f5e..c0e0917b 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 @@ -65,6 +65,8 @@ sealed interface SettingsAction { data class ToggleSensor(val value: Boolean) : SettingsAction data class ToggleLightTheme(val value: Boolean) : SettingsAction data class ToggleNightMode(val value: Boolean) : 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 33a5a5b1..adcac2ad 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 @@ -123,6 +123,8 @@ class SettingsViewModel( is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) } is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) } is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) } + 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) diff --git a/gradle.properties b/gradle.properties index 6308f0e2..8ecdada7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,6 @@ org.gradle.caching=true org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.workers.max=4 + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true From a9dd3e6e554325b9aa13f51d40a7cad8a7cd895b Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Wed, 5 Aug 2026 00:12:10 +0800 Subject: [PATCH 04/18] Localized pass date and time formats for Chinese (#231) Co-authored-by: atsunatsu --- .../look4sat/core/presentation/Components.kt | 7 +++++- .../look4sat/feature/passes/PassesScreen.kt | 7 +++++- .../feature/passes/PassesViewModel.kt | 22 ++++++++++++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) 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 d95eaec3..04010d41 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 @@ -144,7 +144,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 @@ -213,6 +213,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( 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 10a75a46..b39aa06c 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 @@ -292,6 +292,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() { @@ -324,7 +329,7 @@ private fun PassItem( 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 } } val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) } val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) } 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 f8e6b230..284e7456 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 @@ -145,12 +145,28 @@ class PassesViewModel( } } + 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 locale = displayLocale() + val pattern = if (locale.language == Locale.CHINESE.language) { + "yyyy'年'M'月'd'日' EEEE" + } else { + "EEE, dd MMM yyyy" + } + 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 private fun computeSunTimes(passes: List, isUtc: Boolean): Map> { val stationPos = settingsRepo.stationPosition.value val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() - val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz } - val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz } + val sdfDate = dateFormat(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 }) { @@ -173,7 +189,7 @@ class PassesViewModel( private fun groupPasses(passes: List, isUtc: Boolean): Map> { val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() - val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz } + val sdfDate = dateFormat(tz) val ordered = LinkedHashMap>() val deepSpace = passes.filter { it.isDeepSpace } if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace From bd0881fb4bc1de494af72ec4d6cb9769a17cc7de Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Wed, 5 Aug 2026 00:45:12 +0800 Subject: [PATCH 05/18] Added linear transponder Doppler calculator (#232) Co-authored-by: atsunatsu --- .../utility/DopplerFrequencyCalculator.kt | 122 +++++++++++ .../domain/DopplerFrequencyCalculatorTest.kt | 190 ++++++++++++++++++ .../src/main/res/values-zh/strings.xml | 3 + .../src/main/res/values/strings.xml | 3 + .../look4sat/feature/radar/RadarScreen.kt | 30 ++- .../feature/radar/TransceiversPage.kt | 185 ++++++++++++++++- 6 files changed, 525 insertions(+), 8 deletions(-) create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt 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 new file mode 100644 index 00000000..6b5e9575 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -0,0 +1,122 @@ +/* + * 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. + */ +package com.rtbishop.look4sat.core.domain.utility + +import com.rtbishop.look4sat.core.domain.model.SatRadio +import com.rtbishop.look4sat.core.domain.predict.OrbitalPos +import java.util.Locale + +/** + * Computes Doppler-corrected reciprocal frequencies for linear transponders. + * + * For a linear (passband) transponder, uplink and downlink frequencies are + * related by a fixed passband offset. When the satellite moves, both are + * Doppler-shifted. Given one, this computes the other: + * + * downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) + * uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) + */ +object DopplerFrequencyCalculator { + + /** + * Given a downlink frequency, compute the Doppler-corrected uplink frequency. + * Returns null if the transponder is not a linear passband type. + */ + fun computeUplinkFromDownlink( + downlinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null + return orbitalPos.getUplinkFreq(baseUplink) + } + + /** + * Given a downlink frequency, compute the Doppler-corrected uplink frequency + * with an offset applied to the downlink (in Hz). + * Returns null if the transponder is not a linear passband type. + * + * The user-entered downlink frequency already includes the offset, so subtract + * it before mapping the downlink passband position back to the uplink. + */ + fun computeUplinkFromDownlinkWithOffset( + downlinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos, + offsetHz: Long + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null + return orbitalPos.getUplinkFreq(baseUplink) + } + + /** + * Given an uplink frequency, compute the Doppler-corrected downlink frequency. + * Returns null if the transponder is not a linear passband type. + */ + fun computeDownlinkFromUplink( + uplinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null + return orbitalPos.getDownlinkFreq(baseDownlink) + } + + /** + * Given an uplink frequency, compute the Doppler-corrected downlink frequency + * with an offset applied to the downlink (in Hz). + * Returns null if the transponder is not a linear passband type. + */ + fun computeDownlinkFromUplinkWithOffset( + uplinkHz: Long, + transponder: SatRadio, + orbitalPos: OrbitalPos, + offsetHz: Long + ): Long? { + if (!isLinearTransponder(transponder)) return null + val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null + return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz) + } + + /** True if this transponder supports linear passband mapping. */ + fun isLinearTransponder(transponder: SatRadio): Boolean { + val upLow = transponder.uplinkLow + val upHigh = transponder.uplinkHigh + val downLow = transponder.downlinkLow + val downHigh = transponder.downlinkHigh + return upLow != null && upHigh != null && downLow != null && downHigh != null + && upLow != upHigh && downLow != downHigh + } + + /** + * True for the radio entry that should drive the standalone Calculator page. + * + * A frequency range alone is not enough: some non-user-facing or drifting data entries + * can also have low/high frequencies. The calculator is meant for named linear + * transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder". + */ + fun isNamedLinearTransponder(transponder: SatRadio): Boolean { + if (!isLinearTransponder(transponder)) return false + + val info = transponder.info.lowercase(Locale.ENGLISH) + val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) + .joinToString(separator = " ") + .lowercase(Locale.ENGLISH) + val hasLinearName = info.contains("linear") + 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) + } +} 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 new file mode 100644 index 00000000..49e8ebb6 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt @@ -0,0 +1,190 @@ +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.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( + upLow: Long = 145_000_000L, + upHigh: Long = 145_500_000L, + downLow: Long = 435_000_000L, + downHigh: Long? = 435_500_000L, + inverted: Boolean = false, + info: String = "Linear Transponder", + downlinkMode: String? = "USB", + uplinkMode: String? = "LSB" + ) = SatRadio( + uuid = "linear", info = info, isAlive = true, + downlinkLow = downLow, downlinkHigh = downHigh, + downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, + uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345 + ) + + private fun fmTransponder() = SatRadio( + uuid = "fm", info = "FM Repeater", isAlive = true, + downlinkLow = 435_600_000L, downlinkHigh = null, + downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null, + uplinkMode = "FM", isInverted = false, catnum = 99999 + ) + + private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply { + this.distanceRate = distanceRateKmS + } + + @Test + fun isLinearTransponder_returnsTrueForLinear() { + assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder())) + } + + @Test + fun isLinearTransponder_returnsFalseForFM() { + assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder())) + } + + @Test + fun isLinearTransponder_returnsFalseForNullDownlinkHigh() { + val xpdr = linearTransponder(downHigh = null) + assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr)) + } + + @Test + fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() { + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder())) + } + + @Test + fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() { + val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB") + assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr)) + } + + @Test + fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() { + val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)") + assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry)) + } + + @Test + fun isNamedLinearTransponder_returnsFalseForFmRepeater() { + assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder())) + } + + @Test + fun computeUplinkFromDownlink_linear_noDoppler() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + assertEquals(145_200_000L, uplink) + } + + @Test + fun computeDownlinkFromUplink_linear_noDoppler() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos) + assertNotNull(downlink) + assertEquals(435_200_000L, downlink) + } + + @Test + fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() { + // Satellite receding (positive range rate) → ground must transmit higher freq to compensate. + val xpdr = linearTransponder() + val orbitalPos = pos(7.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + assertTrue(uplink!! > 145_200_000L) + } + + @Test + fun computeUplinkFromDownlink_fmTransponder_returnsNull() { + val orbitalPos = pos() + val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos) + assertNull(result) + } + + @Test + fun computeDownlinkFromUplink_fmTransponder_returnsNull() { + val orbitalPos = pos() + val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos) + assertNull(result) + } + + @Test + fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + uplinkHz = 145_200_000L, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = 2_500L + ) + assertEquals(435_202_500L, downlink) + } + + @Test + fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + downlinkHz = 435_202_500L, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = 2_500L + ) + assertEquals(145_200_000L, uplink) + } + + @Test + fun computeOffsetRoundTrip_handlesNegativeOffset() { + val xpdr = linearTransponder() + val orbitalPos = pos(0.0) + val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + uplinkHz = 145_200_000L, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = -2_500L + ) + assertEquals(435_197_500L, downlink) + + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + downlinkHz = downlink!!, + transponder = xpdr, + orbitalPos = orbitalPos, + offsetHz = -2_500L + ) + assertEquals(145_200_000L, uplink) + } + + @Test + fun computeUplinkFromDownlink_invertedTransponder() { + val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L) + val orbitalPos = pos(0.0) + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos) + assertNotNull(uplink) + assertEquals(145_300_000L, uplink) + } + + @Test + fun computeUplinkFromDownlink_roundTrip() { + val xpdr = linearTransponder() + val orbitalPos = pos(3.5) + val originalDownlink = 435_250_000L + val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos) + assertNotNull(uplink) + val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos) + assertNotNull(roundTripDownlink) + val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink) + assertTrue("Round-trip error too large: $error", error < 10000) + } +} diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index d5169543..42507759 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -60,6 +60,9 @@ 否 是 日照中 + 输入上行频率 (MHz) + 输入下行频率 (MHz) + 偏移 (kHz) 上一个 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index d571a14b..f88540cb 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -84,6 +84,9 @@ No Yes Visible + Enter TX freq (MHz) + Enter RX freq (MHz) + Offset (kHz) Prev 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 aa164b32..b94227ca 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 @@ -45,6 +45,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.IconCard @@ -74,6 +76,7 @@ import kotlinx.coroutines.launch private enum class RadarPage(val title: String) { Transceivers("Transceivers"), + Calculator("Calculator"), Sstv("SSTV") } @@ -150,16 +153,31 @@ private fun PagerCard( requestMicPermission: () -> Unit, modifier: Modifier = Modifier ) { - val pages = RadarPage.entries + val hasCalculatorPage = remember(uiState.transceivers.transmitters) { + uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder) + } + val pages = remember(hasCalculatorPage) { + buildList { + add(RadarPage.Transceivers) + if (hasCalculatorPage) add(RadarPage.Calculator) + add(RadarPage.Sstv) + } + } val pagerState = rememberPagerState(pageCount = { pages.size }) val coroutineScope = rememberCoroutineScope() + LaunchedEffect(pages.size) { + val lastPage = pages.lastIndex + if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage) + } + ElevatedCard(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { - PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex) + PrimaryTabRow(selectedTabIndex = selectedTabIndex) { pages.forEachIndexed { index, page -> Tab( - selected = pagerState.currentPage == index, + selected = selectedTabIndex == index, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) } ) @@ -176,6 +194,12 @@ private fun PagerCard( radioControl = uiState.radioControl, onAction = onAction ) + RadarPage.Calculator -> CalculatorPage( + transceivers = uiState.transceivers.transmitters, + selectedUuid = uiState.transceivers.selectedUuid, + orbitalPos = uiState.orbitalPos, + onAction = onAction + ) RadarPage.Sstv -> SstvPage( sstv = uiState.sstv, dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) }, 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 c47f20e1..1a4d2e10 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 @@ -39,14 +39,19 @@ 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.KeyboardOptions import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,11 +60,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType 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.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.presentation.CardButton import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.formatFrequency @@ -104,6 +112,69 @@ fun TransceiversPage( } } +@Composable +fun CalculatorPage( + transceivers: List, + selectedUuid: String?, + orbitalPos: OrbitalPos?, + onAction: (RadarAction) -> Unit, + modifier: Modifier = Modifier +) { + val calculatorTransceivers = remember(transceivers) { + transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) + } + val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { + calculatorTransceivers.firstOrNull { it.uuid == selectedUuid } + ?: calculatorTransceivers.firstOrNull() + } + + if (selectedTransceiver == null) { + EmptyTransceiversContent(modifier) + return + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (calculatorTransceivers.size > 1) { + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + calculatorTransceivers.forEach { radio -> + FilterChip( + selected = radio.uuid == selectedTransceiver.uuid, + onClick = { + if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid)) + }, + label = { + Text( + text = transceiverTitle(radio), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + } + } + + item { + DopplerCalculatorPanel( + transponder = selectedTransceiver, + orbitalPos = orbitalPos, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + @Composable private fun EmptyTransceiversContent(modifier: Modifier = Modifier) { Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) { @@ -172,12 +243,8 @@ private fun TransceiverItem( .rotate(if (isExpanded) 270f else 90f) ) } - // Title with mode - val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info - val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}" - val fullTitle = "$title ($mode)" Text( - text = fullTitle, + text = transceiverTitle(radio), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -437,6 +504,108 @@ private fun ExpandedRadioControl( } } +@Composable +private fun DopplerCalculatorPanel( + transponder: SatRadio, + orbitalPos: OrbitalPos?, + modifier: Modifier = Modifier +) { + if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return + + var txInputMHz by remember(transponder.uuid) { mutableStateOf("") } + var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") } + var offsetKHz by remember(transponder.uuid) { mutableStateOf("") } + var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) } + + fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L + fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0) + fun mhzToHz(text: String): Long? = text.toDoubleOrNull() + ?.takeIf { it > 0.0 } + ?.let { (it * 1_000_000).toLong() } + + fun calculateDownlink(txText: String, offsetText: String): String? { + val txHz = mhzToHz(txText) ?: return null + return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + uplinkHz = txHz, + transponder = transponder, + orbitalPos = orbitalPos, + offsetHz = offsetHz(offsetText) + )?.let(::formatHz) + } + + fun calculateUplink(rxText: String, offsetText: String): String? { + val rxHz = mhzToHz(rxText) ?: return null + return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + downlinkHz = rxHz, + transponder = transponder, + orbitalPos = orbitalPos, + offsetHz = offsetHz(offsetText) + )?.let(::formatHz) + } + + LaunchedEffect(orbitalPos, transponder.uuid) { + if (lastEditedBy == EditedField.TX) { + calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it } + } else { + calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it } + } + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = offsetKHz, + onValueChange = { newValue -> + offsetKHz = newValue + if (lastEditedBy == EditedField.TX) { + calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it } + } else { + calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it } + } + }, + label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier.fillMaxWidth() + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = txInputMHz, + onValueChange = { newValue -> + txInputMHz = newValue + lastEditedBy = EditedField.TX + rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz + }, + label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + + OutlinedTextField( + value = rxInputMHz, + onValueChange = { newValue -> + rxInputMHz = newValue + lastEditedBy = EditedField.RX + txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz + }, + label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + } + } +} + +private enum class EditedField { TX, RX } + @Composable private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { val text = frequency?.let { @@ -452,5 +621,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { ) } +private fun transceiverTitle(radio: SatRadio): String { + val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info + val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}" + return "$title ($mode)" +} + 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") From 602b1553a2c099e6e95d3aa9fb9a14c44d036b4d Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Sat, 1 Aug 2026 14:57:03 +0200 Subject: [PATCH 06/18] v4.4.4 - Icom CAT, components, filters and sources tweaks --- .../core/data/repository/SatelliteRepo.kt | 5 +- .../core/data/repository/SelectionRepo.kt | 11 ++- .../look4sat/core/data/source/RemoteSource.kt | 11 ++- .../look4sat/core/domain/source/Sources.kt | 8 +- .../core/domain/utility/DataParser.kt | 2 +- .../look4sat/core/presentation/Components.kt | 12 ++- .../src/main/res/drawable/ic_like.xml | 9 ++ .../src/main/res/drawable/ic_pound.xml | 9 -- .../src/main/res/drawable/ic_tags.xml | 9 -- .../src/main/res/values-es/strings.xml | 8 +- .../src/main/res/values-ru/strings.xml | 4 +- .../src/main/res/values-si/strings.xml | 8 +- .../src/main/res/values-tr/strings.xml | 8 +- .../src/main/res/values-uk/strings.xml | 4 +- .../src/main/res/values/strings.xml | 20 +++-- .../android/en-US/changelogs/default.txt | 10 ++- .../feature/passes/PassesViewModel.kt | 54 +++++++----- .../look4sat/feature/radar/RadarViewModel.kt | 32 ++++--- .../feature/satellites/SatellitesViewModel.kt | 5 ++ .../feature/settings/SettingsScreen.kt | 87 ++++++++++++++----- gradle.properties | 4 +- gradle/libs.versions.toml | 6 +- 22 files changed, 202 insertions(+), 124 deletions(-) create mode 100644 core/presentation/src/main/res/drawable/ic_like.xml delete mode 100644 core/presentation/src/main/res/drawable/ic_pound.xml delete mode 100644 core/presentation/src/main/res/drawable/ic_tags.xml 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 088ca67d..def16e02 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 @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import java.util.TimeZone +import kotlin.time.Duration.Companion.milliseconds class SatelliteRepo( private val dispatcher: CoroutineDispatcher, @@ -158,7 +159,7 @@ class SatelliteRepo( } } newPasses.sortBy { it.aosTime } - delay(1000) // Simulate loading time for better UX + delay(1000.milliseconds) // Simulate loading time for better UX _passes.update { newPasses } } _isCalculating.value = false @@ -176,7 +177,7 @@ class SatelliteRepo( val inRange = if (aosStartMinute <= aosEndMinute) { aosMinute in aosStartMinute..aosEndMinute } else { - aosMinute >= aosStartMinute || aosMinute <= aosEndMinute + aosMinute !in (aosEndMinute + 1)..>(emptyList()) - private val currentModes = MutableStateFlow(settingsRepo.selectedSatModes.value) private val currentQuery = MutableStateFlow("") // 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 itemsWithModes = currentModes.flatMapLatest { modes: List -> - val catnumSet: Set? = if (modes.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 = localSource.getIdsWithModes(modes) + val ids = localSource.getIdsWithModes(list) if (ids.isEmpty()) null else ids.toHashSet() } currentItems.map { items -> @@ -65,7 +65,7 @@ class SelectionRepo( } } - override fun getCurrentModes() = currentModes.value + override fun getCurrentModes() = settingsRepo.selectedSatModes.value override fun getModesList() = Sources.satelliteModes @@ -78,7 +78,6 @@ class SelectionRepo( } override suspend fun setModes(modes: List) { - currentModes.value = modes settingsRepo.setSelectedSatModes(modes) } 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 300e930d..2678abbf 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 @@ -24,7 +24,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( @@ -46,10 +45,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: Exception) { println("RemoteSource network stream exception: $exception") null 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 52dc76f1..77e150a4 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 @@ -19,10 +19,10 @@ package com.rtbishop.look4sat.core.domain.source object Sources { val satelliteDataUrls = mapOf( - "All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", - "Amsat" to "https://amsat.org/tle/current/nasabare.txt", - "Classified" to "https://www.mmccants.org/tles/classfd.zip", - "McCants" to "https://www.mmccants.org/tles/inttles.zip", + "CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", + "AMSAT" to "https://amsat.org/tle/current/nasabare.txt", + "McCants Classified" to "https://www.mmccants.org/tles/classfd.zip", + "McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip", "R4UAB" to "https://r4uab.ru/satonline.txt", "SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le", "ARISS" to "https://live.ariss.org/iss.txt", 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 f82d2348..a2d52d3a 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 @@ -91,7 +91,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/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 04010d41..30c07d3e 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 @@ -47,6 +47,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable @@ -308,7 +309,8 @@ fun SharedDialog( titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start, content: @Composable (padding: Dp) -> Unit ) { - DialogShell(onDismissRequest = onDismissRequest) { padding -> + val dismissible = onCancel != null + DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -342,10 +344,14 @@ fun SharedDialog( @Composable private fun DialogShell( onDismissRequest: () -> Unit, + dismissible: Boolean = true, content: @Composable (padding: Dp) -> Unit ) { val padding = LocalSpacing.current.large - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden } + ) val stopSheetFling = remember { object : NestedScrollConnection { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { @@ -360,7 +366,7 @@ private fun DialogShell( } } ModalBottomSheet( - onDismissRequest = onDismissRequest, + onDismissRequest = if (dismissible) onDismissRequest else { {} }, sheetState = sheetState, dragHandle = null, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), 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_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 f2f980ff..095ed335 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -155,11 +155,11 @@ Revisa los permisos de red Otros - Mostrar hora UTC + Hora UTC Actualización automática - Animación de radar - Sensores para radar - Filtro nocturno rojo + 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 3641e54e..4c73ec04 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -158,8 +158,8 @@ Время в 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 11482985..32af6348 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -155,11 +155,11 @@ Network අවසර පරික්‍ෂා කරන්න වෙනත් - UTC වේලාව පෙන්වන්න + UTC වේලාව දත්ත ස්වයං යාවත්කාලීන - Radar sweep සජීවිකරණය - Radar සඳහා සංවේදක භාවිතා කරන්න - රතු රාත්‍රී පෙරහන + Radar sweep + සංවේදක + රාත්‍රී පෙරහන උස් උද්දීපනය අඩු < %1$d° diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 72997597..1f96a4aa 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -187,11 +187,11 @@ Ağ izninizi kontrol edin Diğer - UTC saatini göster + UTC saati Veriyi otomatik güncelle - Radar tarama animasyonu - Radar için sensör kullan - Kırmızı gece filtresi + Radar taraması + Sensör + Gece filtresi Elevasyon vurgusu Düşük < %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 bde698d7..8c1b66bf 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -158,8 +158,8 @@ Час в UTC Автооновлення даних Анімація радара - Сенсори для радара - Червоний нічний фільтр + Сенсори + Нічний фільтр Підсвічування висоти Низько < %1$d° diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index f88540cb..6cc651a1 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -55,8 +55,16 @@ \n\nPlease update the database at least weekly to get accurate predictions. What\'s new in Look4Sat - * Added required tweaks to support Android 17 (API 37) - \n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output + * Added small tweaks to Sources and strings. Added official ISS source (#225) + \n* Implemented AOS window and elevation highlight filters (#226) + \n* Added cleartext traffic support for custom TLE URLs (#227) + \n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228) + \n* Added support for Icom IC-705 CAT by ruilvo (#229) + \n* Added configurable Radar offset to the sensors output by qwqtoday (#230) + \n* Localized pass date and time formats for Chinese by atsunatsu (#231) + \n* Added linear transponder Doppler calculator by atsunatsu (#232) + \n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36 + \n* Replaced the types selection dialog with modes selection to avoid confusion @@ -187,11 +195,11 @@ Check your network permission Other - Show UTC time + UTC time Auto-update data - Radar sweep animation - Use sensors for radar - Red night filter + Radar sweep + Use sensors + Night filter 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..6997002f 100644 --- a/fastlane/metadata/android/en-US/changelogs/default.txt +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -1,2 +1,8 @@ -* Added required tweaks to support Android 17 (API 37) -* Added ACCESS_LOCAL_NETWORK permission check to support network data output +* Added ARISS source, tweaked SSTV sensitivity #225 +* Implemented AOS window and elev filter #226 +* Added cleartext traffic support for TLEs #227 +* Fixed OMM import, tweaked error messages #228 +* Added support for Icom CAT by ruilvo #229 +* Added tweakable Radar offset by qwqtoday #230 +* Localized date/time formats by atsunatsu #231 +* Added linear radio Doppler calc by atsunatsu #232 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 284e7456..39d2459e 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 @@ -41,6 +44,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import kotlin.time.Duration.Companion.milliseconds class PassesViewModel( private val satelliteRepo: ISatelliteRepo, @@ -91,32 +95,36 @@ class PassesViewModel( _uiState.update { it.copy(modes = modes) } } } - // Main tick loop: reacts to new passes, then ticks every second + // 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 { - 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 - ) + 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 once per pass-list/UTC change, not every second + val sunTimes = computeSunTimes(filtered, isUtc) + val grouped = groupPasses(filtered, isUtc) + _uiState.update { it.copy(sunTimes = sunTimes, groupedPasses = grouped) } + while (isActive) { + val timeNow = System.currentTimeMillis() + val processed = computePassProgress(filtered, timeNow) + val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) + _uiState.update { + it.copy( + itemsList = processed, + nextPass = nextPass, + nextTime = nextTime, + isNextTimeAos = isAos + ) + } + delay(1000.milliseconds) } - delay(1000) } - } } } 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 83d214fb..be89d4b6 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 @@ -72,6 +72,7 @@ class RadarViewModel( private var transponders: List = emptyList() private var sstvDecoder: SstvDecoder? = null private var sstvRecordingJob: Job? = null + private var sensorCollectionJob: Job? = null // Celestial positions change slowly, recompute at most once per minute private var lastCelestialUpdateMs = 0L @@ -90,15 +91,15 @@ class RadarViewModel( val uiState: StateFlow = _uiState init { - collectCompassSensor() - collectSettingsChanges() + collectSettingsChanges() // also handles initial sensor subscription 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 = @@ -108,9 +109,16 @@ class RadarViewModel( } } + // 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 { @@ -121,6 +129,11 @@ class RadarViewModel( shouldFlipRadar = settings.radarCompassOffsetElev < 0f ) } + // Reactively wire sensor hardware to the compass setting + when { + settings.stateOfSensors -> startSensorCollection() + else -> stopSensorCollection() + } } } } @@ -219,7 +232,7 @@ class RadarViewModel( } override fun onCleared() { - sensorsRepo.disableSensor() + stopSensorCollection() } fun onAction(action: RadarAction) { @@ -405,7 +418,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 @@ -428,7 +441,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 @@ -440,7 +453,6 @@ class RadarViewModel( 218.1, 225.7, 233.6, 241.8, 250.3 ) - fun factory(container: IMainContainer) = viewModelFactory { initializer { RadarViewModel( 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 72bf17fa..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 @@ -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) { 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 a32393ac..53594bfb 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 @@ -25,10 +25,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope 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.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells @@ -220,7 +222,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> 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)) { @@ -242,7 +244,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> } } else { TopBar { - PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) + PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) TopCard( onClick = { safeOpenUri(appUrl) }, version = uiState.appVersionName, @@ -300,8 +302,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> onRadioControlClick = { dialogs.radioControl = true } ) } - item { OtherCard(uiState.otherSettings, onAction) } - item { CardCredits() } + item(span = { GridItemSpan(maxLineSpan) }) { + if (isVerticalLayout) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + OtherCard(uiState.otherSettings, onAction) + CardCredits() + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min) + ) { + OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight()) + CardCredits(modifier = Modifier.weight(1f).fillMaxHeight()) + } + } + } } } } @@ -480,46 +496,56 @@ 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() - .height(404.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)) - } + 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)) } - SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { - onAction(SettingsAction.ToggleNightMode(it)) - } + Spacer(modifier = Modifier.height(4.dp)) } } } @@ -539,6 +565,7 @@ private fun CompassOffsetRow( Text(text = stringResource(id = labelResId)) Text(text = "${value.toInt()}°") } + Spacer(modifier = Modifier.height(4.dp)) Slider( value = value, onValueChange = onValueChange, @@ -558,6 +585,21 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole } } +@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) + } +} + @Composable private fun formatUpdateTime(updateTime: Long): String { val timePattern = stringResource(id = R.string.prefs_updated_time) @@ -592,7 +634,6 @@ private fun CardCredits(modifier: Modifier = Modifier) { ElevatedCard( modifier = modifier .fillMaxWidth() - .height(404.dp) ) { Column( verticalArrangement = Arrangement.SpaceBetween, diff --git a/gradle.properties b/gradle.properties index 8ecdada7..c72e9bb5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +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.workers.max=4 - -# Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true +org.gradle.workers.max=4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f817fc0..cd816a80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "443" +appVersionCode = "444" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.3" +appVersionName = "4.4.4" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry @@ -23,7 +23,7 @@ compose-activity = "1.13.0" compose-lifecycle = "2.11.0" compose-navigation3 = "1.1.5" -google-ksp = "2.3.8" +google-ksp = "2.3.10" kotlin = "2.4.10" kotlin-coroutines = "1.11.0" From 8fbfcb371286ebc54865c9bdb19ac045cfe175b4 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Wed, 5 Aug 2026 13:03:00 +0200 Subject: [PATCH 07/18] v4.4.5 - Pass progress hotfix, swapped modes/filter dialogs --- .../com/rtbishop/look4sat/feature/passes/PassesScreen.kt | 4 ++-- .../rtbishop/look4sat/feature/passes/PassesViewModel.kt | 7 ++++--- gradle/libs.versions.toml | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) 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 b39aa06c..257d6a72 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 @@ -156,7 +156,7 @@ private fun PassesScreen( 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) @@ -165,7 +165,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) } ) } 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 39d2459e..08d3d3ac 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 @@ -106,17 +106,18 @@ class PassesViewModel( .collectLatest { (allPasses, isUtc, showDeepSpace) -> val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace } - // Expensive: recompute once per pass-list/UTC change, not every second + // Expensive: recompute sun times once per items/UTC change, not every second val sunTimes = computeSunTimes(filtered, isUtc) - val grouped = groupPasses(filtered, isUtc) - _uiState.update { it.copy(sunTimes = sunTimes, groupedPasses = grouped) } + _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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd816a80..5f63642f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "444" +appVersionCode = "445" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.4" +appVersionName = "4.4.5" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry From bf25292bf8af5d35db43daffe079659a3c8814b4 Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Sat, 8 Aug 2026 20:10:55 +0800 Subject: [PATCH 08/18] Fixed recalculating Radar track on station position change (#235) Co-authored-by: atsunatsu Co-authored-by: wty2019wty <74123961+wty2019wty@users.noreply.github.com> --- .gitignore | 1 + .../src/main/res/values-zh/strings.xml | 12 +++++----- .../look4sat/feature/radar/RadarViewModel.kt | 22 +++++++++++++++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index d4338562..3034a19c 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ fastlane/readme.md /app/release/output-metadata.json /app/release/ /.kotlin/sessions/ +.hermes/ diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 42507759..024915d0 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -87,7 +87,7 @@ 更新:%s yyyy MMM d - HH:mm:ss - 站位 + 站点位置 GPS 定位 检查您的位置权限 输入位置 @@ -95,11 +95,11 @@ QTH 网格 无效的 QTH 网格 位置更新成功 - 站位 - 站位纬度 - 站位经度 + 站点位置 + 站点纬度 + 站点经度 QTH 定位 - 通过 QTH 设置站位 + 通过 QTH 设置站点位置 卫星数据更新 卫星:%s @@ -155,6 +155,8 @@ 雷达扫描动画 传感器控制雷达 红色夜间模式 + 雷达罗盘水平偏移量 + 雷达罗盘俯仰偏移量 仰角高亮 低 < %1$d° 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 be89d4b6..6ddd57d2 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 @@ -65,8 +65,8 @@ 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() @@ -92,6 +92,7 @@ class RadarViewModel( init { collectSettingsChanges() // also handles initial sensor subscription + collectStationPositionChanges() collectPassAndStartTickLoop() collectRadioTrackingState() } @@ -138,6 +139,23 @@ class RadarViewModel( } } + 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) } + } + } + } + } + } + // --- Pass loading split into focused functions --- private fun collectPassAndStartTickLoop() { From 10eb84690e5c0055d95552c3c037c4dc5f7c33aa Mon Sep 17 00:00:00 2001 From: QIU SHENGMING Date: Sat, 8 Aug 2026 20:26:36 +0800 Subject: [PATCH 09/18] Added AMSAT satellite status tracking page (#234) --- .../core/data/injection/MainContainer.kt | 3 + .../core/data/repository/AmSatRepository.kt | 156 ++++++++ .../look4sat/core/data/source/RemoteSource.kt | 32 ++ .../look4sat/core/domain/model/SatStatus.kt | 37 ++ .../domain/repository/IAmSatRepository.kt | 9 + .../core/domain/repository/IMainContainer.kt | 1 + .../core/domain/source/IRemoteSource.kt | 2 + .../look4sat/core/presentation/MainTheme.kt | 12 +- .../src/main/res/values/strings.xml | 15 + feature/status/build.gradle.kts | 17 + .../feature/status/SatStatusScreen.kt | 370 ++++++++++++++++++ .../feature/status/SatStatusViewModel.kt | 94 +++++ .../src/main/res/drawable/ic_refresh.xml | 12 + settings.gradle.kts | 3 +- 14 files changed, 758 insertions(+), 5 deletions(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt create mode 100644 feature/status/build.gradle.kts create mode 100644 feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt create mode 100644 feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt create mode 100644 feature/status/src/main/res/drawable/ic_refresh.xml 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 75d7f7df..34e4a12c 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 @@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ic705Controller import com.rtbishop.look4sat.core.data.framework.NetworkReporter import com.rtbishop.look4sat.core.data.framework.RadioTrackingService +import com.rtbishop.look4sat.core.data.repository.AmSatRepository import com.rtbishop.look4sat.core.data.repository.DatabaseRepo import com.rtbishop.look4sat.core.data.repository.SatelliteRepo import com.rtbishop.look4sat.core.data.repository.SelectionRepo @@ -66,12 +67,14 @@ import okhttp3.OkHttpClient class MainContainer(private val context: Context) : IMainContainer { private val localSource = provideLocalSource() + private val remoteSource = provideRemoteSource() private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") } override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler) override val settingsRepo = provideSettingsRepo() override val selectionRepo = provideSelectionRepo() override val satelliteRepo = provideSatelliteRepo() override val databaseRepo = provideDatabaseRepo() + override val amSatRepo by lazy { AmSatRepository(remoteSource) } override val radioTrackingService: IRadioTrackingService by lazy { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) 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 new file mode 100644 index 00000000..fe0d058a --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -0,0 +1,156 @@ +package com.rtbishop.look4sat.core.data.repository + +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.model.SatStatusPage +import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository +import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +/** One report from the AMSAT API (data layer model). */ +private data class ApiReport( + val id: String, + val name: String, + val callsign: String, + val report: String, + val gridSquare: String, + val reportedTimeUtcSec: Long +) + +/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */ +class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository { + + private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + 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 = 168, limit = 500) ?: return@withContext null + + val names = parseCatalog(catalogJson) + val reports = parseReports(reportsJson) + + if (names.isEmpty() && reports.isEmpty()) return@withContext null + + val statuses = buildStatuses(names, reports, nowSec) + val reportMap = reports.associate { it.id to toSatReport(it) } + SatStatusPage(System.currentTimeMillis(), statuses, reportMap) + } + + /** Parse catalog JSON to list of satellite names */ + private fun parseCatalog(json: String): List { + return try { + val arr = JSONObject(json).getJSONArray("data") + (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } + } catch (e: Exception) { + emptyList() + } + } + + /** Parse reports JSON to list of ApiReport domain objects */ + private fun parseReports(json: String): List { + return try { + val arr = JSONObject(json).getJSONArray("data") + (0 until arr.length()).mapNotNull { i -> + val o = arr.getJSONObject(i) + val iso = o.optString("reported_time", "") + if (iso.isEmpty()) null else ApiReport( + id = o.optString("id", ""), + name = o.optString("name", ""), + callsign = o.optString("callsign", ""), + report = o.optString("report", ""), + gridSquare = o.optString("grid_square", ""), + reportedTimeUtcSec = parseIsoUtcSec(iso) + ) + } + } catch (e: Exception) { + emptyList() + } + } + + /** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */ + private fun parseIsoUtcSec(iso: String): Long { + return try { + (isoUtcFormat.parse(iso)?.time ?: 0L) / 1000 + } catch (e: Exception) { + 0L + } + } + + /** Build one SatStatus (6 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")) + val labels = (0 until 6).map { d -> + utc.timeInMillis = (nowSec - d * 86400L) * 1000 + "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" + } + return names.map { name -> + val slots = (0 until 72).map { slotIdx -> + val slotStart = nowSec - (slotIdx + 1) * 7200L + val slotEnd = nowSec - slotIdx * 7200L + val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd } + 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 days = (0 until 6).map { d -> + SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12)) + } + SatStatus(name = name, days = days) + } + } + + private fun toSatReport(r: ApiReport): SatReport { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = r.reportedTimeUtcSec * 1000 + val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0') + val y = cal.get(Calendar.YEAR) + val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0') + val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0') + return SatReport( + id = r.id, + statusText = r.report, + call = r.callsign, + grid = r.gridSquare, + dateUtc = "$y-$mo-$d", + timeUtc = "$hh:$mm UTC" + ) + } + + /** Map status text to color value (for UI rendering). */ + private fun statusColorOf(report: String): Long = when (report.lowercase()) { + "heard", "crew active" -> ACTIVE_BLUE + "telemetry only" -> TLM_ORANGE + "not heard" -> NOT_HEARD_PINK + else -> CONFLICT_DEEP_ORANGE + } + + companion object { + // AMSAT official status colors (from amsat.org/status) + private const val ACTIVE_BLUE = 0xFF648FFF + private const val TLM_ORANGE = 0xFFFFB000 + private const val NOT_HEARD_PINK = 0xFFDC267F + private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100 + private const val NO_REPORT_GRAY = 0xFFC0C0C0 + } +} 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 2678abbf..37864bb2 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 @@ -58,4 +58,36 @@ class RemoteSource( null } } + + override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) { + try { + val request = Request.Builder() + .url("https://www.amsat.org/status/api/v1/catalog.php") + .header("User-Agent", "Look4Sat") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + response.body.string() + } + } catch (exception: Exception) { + println("RemoteSource getAmSatCatalog exception: $exception") + null + } + } + + override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) { + try { + val request = Request.Builder() + .url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit") + .header("User-Agent", "Look4Sat") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + response.body.string() + } + } catch (exception: Exception) { + println("RemoteSource getAmSatReports exception: $exception") + null + } + } } 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 new file mode 100644 index 00000000..22c9fae8 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt @@ -0,0 +1,37 @@ +package com.rtbishop.look4sat.core.domain.model + +/** One satellite status report (AMSAT site tooltip data) */ +data class SatReport( + val id: String, // 报告 ID(a885153) + val statusText: String, // Heard / Telemetry Only / Not Heard ... + val call: String, // 呼号 + val grid: String, // 网格坐标(可为空) + val dateUtc: String, // 2026-08-04 + val timeUtc: String // 2:46-:59 UTC +) + +/** State of one 2-hour slot */ +data class SatSlot( + val statusColor: Long, // ARGB 状态色(-1 = 无报告) + val count: Int, // 报告数量(0 = 无) + val reportIds: List = emptyList() // 该槽报告 ID 列表 +) + +/** One satellite day (12 two-hour slots) */ +data class SatDay( + val dateLabel: String, // "Aug 4" + val slots: List // 12 槽(00-02 ... 22-24) +) + +/** One satellite, 6 days of state */ +data class SatStatus( + val name: String, // "AO-123_[FM]" + val days: List // 6 天(新→旧) +) + +/** Overall page parse result */ +data class SatStatusPage( + val fetchedAtUtcMs: Long, + val statuses: List, + val reports: Map // id → 报告 +) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt new file mode 100644 index 00000000..71d37add --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt @@ -0,0 +1,9 @@ +package com.rtbishop.look4sat.core.domain.repository + +import com.rtbishop.look4sat.core.domain.model.SatStatusPage + +/** AMSAT satellite status data source */ +interface IAmSatRepository { + /** Fetch and parse the AMSAT status page; null on failure */ + suspend fun fetchStatus(): SatStatusPage? +} 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 34e3e8fa..5b077abf 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 @@ -29,6 +29,7 @@ interface IMainContainer { val selectionRepo: ISelectionRepo val satelliteRepo: ISatelliteRepo val databaseRepo: IDatabaseRepo + val amSatRepo: IAmSatRepository val radioTrackingService: IRadioTrackingService fun provideAddToCalendar(): IAddToCalendar fun provideShowToast(): IShowToast diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index 1ba5d62d..6e1ccfff 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -22,4 +22,6 @@ import java.io.InputStream interface IRemoteSource { suspend fun getFileStream(uri: String): InputStream? suspend fun getNetworkStream(url: String): InputStream? + suspend fun getAmSatCatalog(): String? + suspend fun getAmSatReports(hours: Int, limit: Int): String? } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt index f0864b41..80f4ade9 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt @@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme( onSecondary = Color(0xFFFFFFFF), secondaryContainer = Color(0xFFF1E1BB), onSecondaryContainer = Color(0xFF221B04), + tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme) + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker) + onTertiaryContainer = Color(0xFF000000), background = Color(0xFFFFF8F0), onBackground = Color(0xFF1E1B13), surface = Color(0xFFFFF8F0), @@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme( onSecondary = Color(0xFF000000), secondaryContainer = Color(0xFF404040), // navBar indicator, onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon -// tertiary = Color(0xFF121212), -// onTertiary = Color(0xFF121212), -// tertiaryContainer = Color(0xFF121212), -// onTertiaryContainer = Color(0xFF121212), + tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status) + onTertiary = Color(0xFF000000), + tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry + onTertiaryContainer = Color(0xFF000000), background = Color(0xFF121212), onBackground = Color(0xFFE0E0E0), surface = Color(0xFF202020), // card background diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 6cc651a1..06189736 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -67,6 +67,21 @@ \n* Replaced the types selection dialog with modes selection to avoid confusion + + AMSAT + AMSAT Satellite Status + Refresh + Retry + Updated: + Active + TLM/Beacon + Not Heard + Conflicting + Name + Load failed - check network and retry + No reports for this period + Close + Back Notify diff --git a/feature/status/build.gradle.kts b/feature/status/build.gradle.kts new file mode 100644 index 00000000..77b30387 --- /dev/null +++ b/feature/status/build.gradle.kts @@ -0,0 +1,17 @@ +import com.android.build.api.dsl.LibraryExtension + +plugins { + alias(libs.plugins.convention.featurePlugin) +} + +android { + namespace = "com.rtbishop.look4sat.feature.status" + compileOptions { + encoding = "UTF-8" + } +} + +dependencies { + implementation(project(":core:domain")) + implementation(project(":core:presentation")) +} 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 new file mode 100644 index 00000000..202cfcde --- /dev/null +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -0,0 +1,370 @@ +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.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.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.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 +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.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.presentation.R +import com.rtbishop.look4sat.feature.status.R as StatusR +import java.util.Calendar + +/** + * Map AMSAT status text to Material3 colorScheme colors. + * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. + */ +@Composable +private fun statusColorOf(statusText: String): Color { + return when { + statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> + MaterialTheme.colorScheme.tertiary // Active + statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> + MaterialTheme.colorScheme.tertiaryContainer // Telemetry + statusText.contains("Not Heard", ignoreCase = true) -> + Color(0xFFDC267F) // NotHeard pink (no semantic slot) + else -> + MaterialTheme.colorScheme.error // Conflict + } +} + +private val NoReportGray = Color(0xFFC0C0C0) // Neutral state + +@Composable +fun SatStatusScreen(container: IMainContainer) { + val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + 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() } + ) + LegendRow() + + when { + uiState.isLoading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + uiState.error != null && uiState.statuses.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + 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)) + } + } + } + else -> { + // Header + HeaderRow(statuses = uiState.statuses) + HorizontalDivider(thickness = 1.dp) + // Row + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + items(uiState.statuses, key = { it.name }) { status -> + StatusRow( + status = status, + onClickDay = { day -> selectedDay = status to day } + ) + } + } + } + } + } + + selectedDay?.let { (status, day) -> + 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" + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onRefresh), + contentAlignment = Alignment.Center + ) { + if (isRefreshing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Image( + painter = painterResource(id = StatusR.drawable.ic_refresh), + contentDescription = stringResource(id = R.string.amsat_refresh), + modifier = Modifier + .size(18.dp) + .rotate(angle), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + } + } + } +} + +/** Legend (official four colors) */ +@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_not_heard) to Color(0xFFDC267F), + stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + legend.forEach { (label, color) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(2.dp)) + .background(color) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** Header: satellite name + 6 day dates */ +@Composable +private fun HeaderRow(statuses: List) { + val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList() + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.amsat_name), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(2f).padding(start = 4.dp) + ) + dates.forEach { date -> + Text( + text = date, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(0.8f) + ) + } + } +} + +/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */ +@Composable +private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { + val noReportGray = 0xFFC0C0C0L + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = status.name, + fontSize = 11.sp, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.weight(2f).padding(start = 4.dp) + ) + status.days.forEach { day -> + val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() + DayCell( + slot = slot, + modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp), + onClick = { onClickDay(day) } + ) + } + } +} + +/** 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) + .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 + ) + } + } +} + +/** Report detail dialog (3 levels: 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 + ) + } + HorizontalDivider(thickness = 0.5.dp) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) } + } + ) +} + + +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) + ) +} 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 new file mode 100644 index 00000000..70788fb3 --- /dev/null +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt @@ -0,0 +1,94 @@ +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.IMainContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class SatStatusUiState( + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val statuses: List = emptyList(), + val reports: Map = emptyMap(), + val fetchedAtUtcMs: Long = 0L, + val error: String? = null +) + +class SatStatusViewModel( + private val container: IMainContainer +) : ViewModel() { + + private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) + val uiState: StateFlow = _uiState + + init { + fetch() + } + + fun fetch() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + try { + val page = container.amSatRepo.fetchStatus() + if (page != null && page.statuses.isNotEmpty()) { + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + statuses = page.statuses, + reports = page.reports, + fetchedAtUtcMs = page.fetchedAtUtcMs + ) + } + } else { + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed") + } + } + } catch (exception: Exception) { + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed") + } + } + } + } + + fun refresh() { + if (_uiState.value.isRefreshing) return + _uiState.update { it.copy(isRefreshing = true, error = null) } + viewModelScope.launch { + try { + val page = container.amSatRepo.fetchStatus() + if (page != null && page.statuses.isNotEmpty()) { + _uiState.update { + it.copy( + isRefreshing = false, + statuses = page.statuses, + reports = page.reports, + fetchedAtUtcMs = page.fetchedAtUtcMs + ) + } + } else { + _uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") } + } + } catch (exception: Exception) { + _uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") } + } + } + } + + companion object { + fun factory(container: IMainContainer) = viewModelFactory { + initializer { + SatStatusViewModel(container) + } + } + } +} diff --git a/feature/status/src/main/res/drawable/ic_refresh.xml b/feature/status/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..909aa74e --- /dev/null +++ b/feature/status/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 667e5214..42134982 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,5 +25,6 @@ include( ":feature:passes", ":feature:radar", ":feature:satellites", - ":feature:settings" + ":feature:settings", + ":feature:status" ) From 8b5960282fe78ca2dbf8bf8180fb69f7f3af5e75 Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Sun, 9 Aug 2026 03:31:44 +0800 Subject: [PATCH 10/18] Broaden linear transponder detection, logic fixes (#236) Co-authored-by: atsunatsu --- .../core/data/repository/SatelliteRepo.kt | 31 +- .../utility/DopplerFrequencyCalculator.kt | 23 +- .../domain/DopplerFrequencyCalculatorTest.kt | 85 +++- .../feature/radar/TransceiversPage.kt | 421 ++++++++++++++---- 4 files changed, 466 insertions(+), 94 deletions(-) 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 def16e02..464f870b 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,6 +34,7 @@ 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.withContext import java.util.TimeZone @@ -64,19 +65,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 = settingsRepo.selectedSatModes.value - ) - } + 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 { 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 6b5e9575..1bd9037c 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 @@ -112,11 +112,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/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt index 49e8ebb6..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 @@ -13,6 +13,7 @@ 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, @@ -22,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 @@ -72,11 +73,93 @@ 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() 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 1a4d2e10..cea8114a 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,14 +21,18 @@ 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.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 @@ -39,16 +43,20 @@ 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.FilterChip import androidx.compose.material3.HorizontalDivider 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.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 @@ -59,6 +67,7 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color 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 @@ -68,6 +77,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 @@ -122,6 +132,7 @@ fun CalculatorPage( ) { val calculatorTransceivers = remember(transceivers) { transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) + .let(DopplerFrequencyCalculator::deduplicateTransponders) } val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { calculatorTransceivers.firstOrNull { it.uuid == selectedUuid } @@ -166,7 +177,7 @@ fun CalculatorPage( } item { - DopplerCalculatorPanel( + DopplerFrequencyCalculator( transponder = selectedTransceiver, orbitalPos = orbitalPos, modifier = Modifier.fillMaxWidth() @@ -504,108 +515,361 @@ private fun ExpandedRadioControl( } } +private enum class EditedField { TX, PASSBAND, RX } + @Composable -private fun DopplerCalculatorPanel( +private fun DopplerFrequencyCalculator( transponder: SatRadio, orbitalPos: OrbitalPos?, modifier: Modifier = Modifier ) { if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return - var txInputMHz by remember(transponder.uuid) { mutableStateOf("") } - var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") } - var offsetKHz by remember(transponder.uuid) { mutableStateOf("") } - var lastEditedBy by remember(transponder.uuid) { 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 offsetKHz by remember { mutableStateOf("") } + var stepSizeKHz by remember { mutableIntStateOf(1) } - fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L - fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0) - fun mhzToHz(text: String): Long? = text.toDoubleOrNull() - ?.takeIf { it > 0.0 } - ?.let { (it * 1_000_000).toLong() } + val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L - fun calculateDownlink(txText: String, offsetText: String): String? { - val txHz = mhzToHz(txText) ?: return null - return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( - uplinkHz = txHz, - transponder = transponder, - orbitalPos = orbitalPos, - offsetHz = offsetHz(offsetText) - )?.let(::formatHz) + 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) + } } - fun calculateUplink(rxText: String, offsetText: String): String? { - val rxHz = mhzToHz(rxText) ?: return null - return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( - downlinkHz = rxHz, - transponder = transponder, - orbitalPos = orbitalPos, - offsetHz = offsetHz(offsetText) - )?.let(::formatHz) + // 实时重算:卫星移动时,锚点侧不变,重算另一侧 + LaunchedEffect(orbitalPos) { + 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 + } } - LaunchedEffect(orbitalPos, transponder.uuid) { - if (lastEditedBy == EditedField.TX) { - calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it } - } else { - calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it } + 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 -> { + 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(8.dp) + modifier = modifier.fillMaxWidth().padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - OutlinedTextField( - value = offsetKHz, - onValueChange = { newValue -> - offsetKHz = newValue - if (lastEditedBy == EditedField.TX) { - calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it } - } else { - calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it } + 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 = { offsetKHz = it }, + 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() + } + ) + } + } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = txInputMHz, - onValueChange = { newValue -> - txInputMHz = newValue - lastEditedBy = EditedField.TX - rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz - }, - label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) + // 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 + ) + } + 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) ) - OutlinedTextField( - value = rxInputMHz, - onValueChange = { newValue -> - rxInputMHz = newValue - lastEditedBy = EditedField.RX - txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz - }, - label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) - ) + // 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 + ) + } + 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 FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { val text = frequency?.let { @@ -629,3 +893,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") + From 060fa2dfcd1a987fa6487130375fdf3cc5cd92c8 Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Tue, 11 Aug 2026 20:02:15 +0800 Subject: [PATCH 11/18] Added remembering per-satellite doppler offset (#237) Co-authored-by: atsunatsu --- .../core/data/repository/SettingsRepo.kt | 25 ++++++++++++++++++- .../core/data/repository/DatabaseRepoTest.kt | 4 +++ .../core/data/repository/SelectionRepoTest.kt | 4 +++ .../core/domain/repository/ISettingsRepo.kt | 5 ++++ .../look4sat/feature/radar/RadarScreen.kt | 3 ++- .../look4sat/feature/radar/RadarState.kt | 6 ++++- .../look4sat/feature/radar/RadarViewModel.kt | 21 +++++++++++++++- .../feature/radar/TransceiversPage.kt | 8 ++++-- 8 files changed, 70 insertions(+), 6 deletions(-) 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 283142ac..5abc6d63 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 @@ -37,6 +37,7 @@ 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 locationManager: LocationManager, @@ -417,5 +418,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 (e: 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 (e: Exception) { + """{"$catnum": "$offset"}""" + } + preferences.edit { putString(keySatelliteOffsets, updated) } + } } 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 a57c0ac8..e8b051fe 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 @@ -213,6 +213,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 index 9a240b46..8bb71983 100644 --- 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 @@ -167,6 +167,10 @@ class SelectionRepoTest { 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/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 96d0283d..b1b11733 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 @@ -75,4 +75,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/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 b94227ca..53b47670 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 @@ -198,7 +198,8 @@ private fun PagerCard( transceivers = uiState.transceivers.transmitters, selectedUuid = uiState.transceivers.selectedUuid, orbitalPos = uiState.orbitalPos, - onAction = onAction + onAction = onAction, + calculatorOffsetKHz = uiState.calculatorOffsetKHz ) RadarPage.Sstv -> SstvPage( sstv = uiState.sstv, 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 78aaadd9..0383b85d 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 @@ -63,7 +63,8 @@ data class RadarState( val moonPosition: CelestialComputer.MoonPosition? = null, val transceivers: TransceiverSubState = TransceiverSubState(), val radioControl: RadioControlSubState = RadioControlSubState(), - val sstv: SstvSubState = SstvSubState() + val sstv: SstvSubState = SstvSubState(), + val calculatorOffsetKHz: String = "" ) enum class SstvStatus { Idle, Recording } @@ -97,4 +98,7 @@ sealed interface RadarAction { data object SstvReset : RadarAction data class SstvSelectMode(val modeName: String) : RadarAction data class SstvPermissionResult(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/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index 6ddd57d2..55e4ec45 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 @@ -260,7 +260,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) { @@ -309,6 +319,15 @@ class RadarViewModel( sstvDecoder?.clearPixels() _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } } + 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) } + } } } 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 cea8114a..81969568 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 @@ -128,6 +128,7 @@ fun CalculatorPage( selectedUuid: String?, orbitalPos: OrbitalPos?, onAction: (RadarAction) -> Unit, + calculatorOffsetKHz: String = "", modifier: Modifier = Modifier ) { val calculatorTransceivers = remember(transceivers) { @@ -180,6 +181,8 @@ fun CalculatorPage( DopplerFrequencyCalculator( transponder = selectedTransceiver, orbitalPos = orbitalPos, + offsetKHz = calculatorOffsetKHz, + onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) }, modifier = Modifier.fillMaxWidth() ) } @@ -521,6 +524,8 @@ private enum class EditedField { TX, PASSBAND, RX } private fun DopplerFrequencyCalculator( transponder: SatRadio, orbitalPos: OrbitalPos?, + offsetKHz: String = "", + onOffsetChange: (String) -> Unit = {}, modifier: Modifier = Modifier ) { if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return @@ -529,7 +534,6 @@ private fun DopplerFrequencyCalculator( var txFrequencyHz by remember { mutableStateOf(0L) } var rxFrequencyHz by remember { mutableStateOf(0L) } var passbandPosition by remember { mutableStateOf(0.5f) } - var offsetKHz by remember { mutableStateOf("") } var stepSizeKHz by remember { mutableIntStateOf(1) } val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L @@ -712,7 +716,7 @@ private fun DopplerFrequencyCalculator( ) { BasicTextField( value = offsetKHz, - onValueChange = { offsetKHz = it }, + onValueChange = onOffsetChange, singleLine = true, textStyle = TextStyle( fontSize = 16.sp, From 3f5b48f27015df6673e37271487b44f03f81ee6b Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Mon, 10 Aug 2026 12:14:26 +0200 Subject: [PATCH 12/18] Integrated the AMSAT status page created by MCKero6423 --- .../com/rtbishop/look4sat/MainActivity.kt | 2 +- .../java/com/rtbishop/look4sat/MainScreen.kt | 177 +++++------ .../look4sat/convention/ApplicationPlugin.kt | 1 + .../core/data/repository/AmSatRepository.kt | 24 +- .../look4sat/core/domain/model/SatStatus.kt | 4 +- .../look4sat/core/presentation/Navigation.kt | 4 +- .../src/main/res/drawable/ic_radar.xml | 9 - .../src/main/res/drawable/ic_refresh.xml | 6 +- .../src/main/res/drawable/ic_satellite.xml | 31 ++ .../{ic_satellites.xml => ic_sputnik.xml} | 0 .../src/main/res/values/strings.xml | 1 + .../feature/settings/SettingsScreen.kt | 2 +- .../feature/status/SatStatusScreen.kt | 282 ++++++++---------- .../feature/status/SatStatusViewModel.kt | 9 +- 14 files changed, 272 insertions(+), 280 deletions(-) delete mode 100644 core/presentation/src/main/res/drawable/ic_radar.xml rename {feature/status => core/presentation}/src/main/res/drawable/ic_refresh.xml (75%) create mode 100644 core/presentation/src/main/res/drawable/ic_satellite.xml rename core/presentation/src/main/res/drawable/{ic_satellites.xml => ic_sputnik.xml} (100%) 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 0efa0fe2..c4345b1a 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -25,8 +25,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 @@ -44,7 +42,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 @@ -70,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds @@ -82,37 +81,42 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination +import com.rtbishop.look4sat.feature.status.SatStatusDestination @Composable fun NavRoot(deeplink: String? = null) { val rootBackStack = rememberNavBackStack(Screen.Passes) val deeplinkResolver = DeeplinkResolver() LaunchedEffect(deeplink) { - deeplink?.let { - 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(350)) { it } togetherWith + slideOutHorizontally(tween(350)) { -it / 3 } + // Reverse: outgoing slides out to the right, incoming drifts in from the left + val popTransition = slideInHorizontally(tween(350)) { -it / 3 } togetherWith + slideOutHorizontally(tween(350)) { 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(navigateToRadar = { rootBackStack.add(RadarDestination) }) } + entry { MainScreen(navigateToRadar = navigateToRadar) } entry { - Scaffold { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { RadarDestination(navigateUp = navigateBack) - innerPadding.calculateTopPadding() } } } @@ -120,18 +124,20 @@ fun NavRoot(deeplink: String? = null) { } @Composable -fun MainScreen(navigateToRadar: () -> Unit = {}) { - val backStack = rememberNavBackStack(Screen.Passes) - val currentKey = backStack.lastOrNull() - val navigateBack: () -> Unit = { backStack.removeLastOrNull() } - val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350)) - val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings) - +private fun MainScreen(navigateToRadar: () -> Unit = {}) { val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() + val backStack = rememberNavBackStack(Screen.Passes) + val currentKey = backStack.lastOrNull() + val navigateBack: () -> Unit = { backStack.removeLastOrNull() } + val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith + fadeOut(animationSpec = tween(350)) + val navItems = + listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings) + CompositionLocalProvider( LocalElevationThresholds provides ElevationThresholds( low = otherSettings.lowElevation, @@ -144,13 +150,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { val isSelected = when (currentKey) { is Screen.Satellites -> screen is Screen.Satellites is Screen.Passes -> screen is Screen.Passes - is Screen.Radar -> screen is Screen.Radar + is Screen.Status -> screen is Screen.Status is Screen.Map -> screen is Screen.Map is Screen.Settings -> screen is Screen.Settings else -> false } item( - icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, + icon = { + Icon( + painter = painterResource(screen.iconResId), + contentDescription = stringResource(screen.titleResId) + ) + }, label = { Text(stringResource(screen.titleResId)) }, selected = isSelected, onClick = { @@ -179,9 +190,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { popTransitionSpec = { fadeTransition }, predictivePopTransitionSpec = { fadeTransition }, entryDecorators = listOf( - // Required for saving Compose state per entry rememberSaveableStateHolderNavEntryDecorator(), - // Required for ViewModel scoping per entry rememberViewModelStoreNavEntryDecorator() ), entryProvider = entryProvider { @@ -191,69 +200,71 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { entry { PassesDestination { catNum, aosTime -> container.satelliteRepo.selectPass(catNum, aosTime) - backStack.add(Screen.Radar) -// navigateToRadar() + navigateToRadar() } } - entry { - RadarDestination(navigateUp = navigateBack) - } - entry { - MapDestination() - } - entry { - SettingsDestination() - } + entry { SatStatusDestination() } + entry { MapDestination() } + entry { SettingsDestination() } } ) - // Radio tracking status banner if (trackingState.isActive) { - val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") - val alpha by infiniteTransition.animateFloat( - initialValue = 1f, targetValue = 0.4f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), label = "pulseAlpha" - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .clickable { - val pass = trackingState.currentPass - if (pass != null) { - container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) - backStack.add(Screen.Radar) - } + TrackingBanner( + state = trackingState, + onClick = { + val pass = trackingState.currentPass + if (pass != null) { + container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) + navigateToRadar() } - .padding(horizontal = 12.dp, vertical = 6.dp) - ) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(Color(0xFF4CAF50).copy(alpha = alpha)) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Tracking: ${trackingState.currentPass?.name ?: ""}", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.weight(1f) - ) - val txOk = if (trackingState.txConnected) "TX" else "" - val rxOk = if (trackingState.rxConnected) "RX" else "" - Text( - text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } + } + ) } } } } } + +@Composable +private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) { + val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.4f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse + ), + label = "pulseAlpha" + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50).copy(alpha = alpha)) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Tracking: ${state.currentPass?.name ?: ""}", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.weight(1f) + ) + val connections = listOfNotNull( + "TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected } + ) + Text( + text = connections.joinToString("/"), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } +} diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index 1b33372f..cdb95975 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin { implementation(project(":feature:radar")) implementation(project(":feature:satellites")) implementation(project(":feature:settings")) + implementation(project(":feature:status")) implementation(libs.androidx.core.splashscreen) implementation(libs.compose.material3.adaptive) implementation(libs.compose.navigation3) 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 fe0d058a..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,13 +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 = 168, limit = 500) ?: 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) @@ -52,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() } } @@ -73,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor reportedTimeUtcSec = parseIsoUtcSec(iso) ) } - } catch (e: Exception) { + } catch (_: Exception) { emptyList() } } @@ -82,22 +84,22 @@ 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")) - val labels = (0 until 6).map { d -> + 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 slots = (0 until 72).map { slotIdx -> + 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 } @@ -112,7 +114,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor ) } } - val days = (0 until 6).map { d -> + val days = (0 until 3).map { d -> SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12)) } SatStatus(name = name, days = days) 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/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 b0ff0c1b..4da16eb2 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) : NavKey { @Serializable - data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat) + data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat) @Serializable data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass) @Serializable - data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar) + data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status) @Serializable data object Map : Screen(R.drawable.ic_map, R.string.nav_map) 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/feature/status/src/main/res/drawable/ic_refresh.xml b/core/presentation/src/main/res/drawable/ic_refresh.xml similarity index 75% rename from feature/status/src/main/res/drawable/ic_refresh.xml rename to core/presentation/src/main/res/drawable/ic_refresh.xml index 909aa74e..9e52eb73 100644 --- a/feature/status/src/main/res/drawable/ic_refresh.xml +++ b/core/presentation/src/main/res/drawable/ic_refresh.xml @@ -1,12 +1,10 @@ - + android:viewportHeight="24"> 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/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 06189736..3d6250d2 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ Radar Map Settings + AMSAT Modes: %s 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 53594bfb..dd71422f 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 @@ -667,7 +667,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 ) 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 202cfcde..ca301e11 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,24 @@ 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.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,11 +32,15 @@ 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 @@ -55,11 +49,15 @@ 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.R -import com.rtbishop.look4sat.feature.status.R as StatusR +import com.rtbishop.look4sat.core.presentation.SharedDialog +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. @@ -68,37 +66,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 SatStatusScreen(container: IMainContainer) { +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() - var selectedDay by remember { mutableStateOf?>(null) } + SatStatusScreen(uiState) { viewModel.refresh() } +} - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding() - .padding(horizontal = 8.dp) - ) { - // Top: legend + refresh - StatusHeader( - fetchedAtUtcMs = uiState.fetchedAtUtcMs, - isRefreshing = uiState.isRefreshing, - onRefresh = { viewModel.refresh() } - ) +@Composable +private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) { + var selectedDay by remember { mutableStateOf?>(null) } + Column(modifier = Modifier.fillMaxSize().layoutPadding()) { + StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) LegendRow() when { @@ -113,29 +104,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 }) } } } @@ -143,56 +121,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) ) } @@ -200,171 +163,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 -> 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] } + SharedDialog(title = "$statusName · ${day.dateLabel}", onCancel = onDismiss, onAccept = onDismiss) { + if (dayReports.isEmpty()) { + Text(stringResource(id = R.string.amsat_no_reports)) + Spacer(modifier = Modifier.height(8.dp)) + } else { + LazyColumn { + 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 70788fb3..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 @@ -6,6 +6,7 @@ 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,7 +23,7 @@ data class SatStatusUiState( ) class SatStatusViewModel( - private val container: IMainContainer + private val amSatRepo: IAmSatRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) @@ -36,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( @@ -65,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( @@ -87,7 +88,7 @@ class SatStatusViewModel( companion object { fun factory(container: IMainContainer) = viewModelFactory { initializer { - SatStatusViewModel(container) + SatStatusViewModel(container.amSatRepo) } } } From 24eebdef74c4605f1d86addc53e554b931c0a785 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Tue, 11 Aug 2026 13:46:31 +0200 Subject: [PATCH 13/18] Consolidated app dialogs and tweaked bottom sheets --- .../look4sat/core/presentation/Components.kt | 106 ++++++++++++------ .../look4sat/feature/passes/PassesDialog.kt | 6 +- .../look4sat/feature/passes/PassesScreen.kt | 18 +-- .../look4sat/feature/radar/SstvPage.kt | 12 +- .../feature/satellites/SatellitesDialog.kt | 4 +- .../feature/satellites/SatellitesScreen.kt | 14 +-- .../feature/settings/SettingsDialog.kt | 14 +-- .../feature/settings/SettingsScreen.kt | 13 ++- .../feature/status/SatStatusScreen.kt | 7 +- 9 files changed, 110 insertions(+), 84 deletions(-) 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 30c07d3e..b9ffe81e 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,11 +27,15 @@ 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 @@ -47,7 +51,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable @@ -73,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 @@ -291,52 +296,81 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass( ) @Composable -fun SharedDialog( - title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit -) { - SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ -> - content() - } -} - -@Composable -fun SharedDialog( +fun InfoDialog( title: String, - onDismissRequest: () -> Unit, - onCancel: (() -> Unit)? = null, - onAccept: (() -> Unit)? = null, - titleFontSize: Int = 16, - titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start, - content: @Composable (padding: Dp) -> Unit + onDismiss: () -> Unit, + onAccept: () -> Unit, + content: @Composable () -> Unit ) { - val dismissible = onCancel != null - DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding -> + DialogShell(onDismissRequest = onDismiss) { padding -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(start = padding, top = padding, end = padding) ) { - if (onCancel != null) { - CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel)) - } Text( text = title, - fontSize = titleFontSize.sp, + fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary, - textAlign = titleTextAlign, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) + } + content() + } +} + +@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 = if (onCancel != null && onAccept != null) padding else 0.dp) + .padding(horizontal = padding) ) - if (onAccept != null) { - CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) - } + CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) } - content(padding) + 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)) } } @@ -344,14 +378,13 @@ fun SharedDialog( @Composable private fun DialogShell( onDismissRequest: () -> Unit, - dismissible: Boolean = true, content: @Composable (padding: Dp) -> Unit ) { val padding = LocalSpacing.current.large - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden } - ) + 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 { @@ -366,10 +399,10 @@ private fun DialogShell( } } ModalBottomSheet( - onDismissRequest = if (dismissible) onDismissRequest else { {} }, + onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), scrimColor = Color.Black.copy(alpha = 0.64f) ) { Column( @@ -377,6 +410,7 @@ private fun DialogShell( verticalArrangement = Arrangement.spacedBy(padding), modifier = Modifier .fillMaxWidth() + .heightIn(max = maxSheetHeight) .nestedScroll(stopSheetFling) ) { content(padding) 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 4e1aa773..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,7 +58,7 @@ import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.core.presentation.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 @@ -140,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, @@ -365,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 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 257d6a72..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 @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -72,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 @@ -136,20 +135,7 @@ 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) - ) - Spacer(modifier = Modifier.height(0.dp)) - } + WhatsNewDialog(onDismiss = dismiss) } ScreenColumn( topBar = { isVerticalLayout -> 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/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 2147f9f3..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,9 +39,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.core.presentation.SharedDialog @Preview(showBackground = true) @Composable @@ -59,7 +59,7 @@ internal fun MultiModesDialog( selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode } val onAccept = { accept(selected.value.toList()) } - 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 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 33441d7c..4083dccd 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 @@ -96,17 +97,16 @@ private fun SatellitesScreen( } 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)) } 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 42ca9169..43baaf24 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 @@ -49,7 +49,7 @@ import com.rtbishop.look4sat.core.presentation.CardButton 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 @Preview(showBackground = true) @Composable @@ -63,7 +63,7 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double, val lonValue = rememberSaveable { mutableStateOf(lon.toString()) } val titleText = stringResource(id = R.string.prefs_station_title) val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } } - SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) { + ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) { Row( horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier @@ -107,7 +107,7 @@ private fun LocatorDialogPreview() { fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) { val locator = rememberSaveable { mutableStateOf(qthLocator) } val onAccept = { save(locator.value).also { dismiss() } } - SharedDialog( + ConfirmDialog( title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept @@ -169,7 +169,7 @@ fun DataSourcesDialog( onDismiss() } val onCancel = { onDismiss() } - SharedDialog( + ConfirmDialog( title = stringResource(id = R.string.prefs_data_sources_title), onCancel = onCancel, onAccept = onAccept, @@ -313,7 +313,7 @@ fun NetworkOutputDialog( ) onDismiss() } - SharedDialog( + ConfirmDialog( title = stringResource(R.string.prefs_net_title), onCancel = onDismiss, onAccept = onAccept @@ -411,7 +411,7 @@ fun BluetoothOutputDialog( ) onDismiss() } - SharedDialog( + ConfirmDialog( title = stringResource(R.string.prefs_bt_title), onCancel = onDismiss, onAccept = onAccept @@ -538,7 +538,7 @@ fun RadioControlDialog( onDismiss() } - SharedDialog( + ConfirmDialog( title = stringResource(R.string.rc_settings_title), onCancel = onDismiss, onAccept = onAccept 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 dd71422f..63c3cf0f 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 @@ -74,6 +74,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.presentation.isVerticalLayout import java.text.SimpleDateFormat @@ -198,6 +199,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> onSave = { onAction(SettingsAction.UpdateRadioControl(it)) } ) } + if (dialogs.whatsNew) { + WhatsNewDialog(onDismiss = { dialogs.whatsNew = false }) + } // URLs for top bar val uriHandler = LocalUriHandler.current @@ -218,7 +222,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> if (isVerticalLayout) { TopBar { TopCard( - onClick = { safeOpenUri(appUrl) }, + onClick = { dialogs.whatsNew = true }, version = uiState.appVersionName, modifier = Modifier.weight(1f) ) @@ -246,7 +250,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> TopBar { PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) TopCard( - onClick = { safeOpenUri(appUrl) }, + onClick = { dialogs.whatsNew = true }, version = uiState.appVersionName, modifier = Modifier.weight(1f) ) @@ -718,6 +722,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 @@ -725,12 +730,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] } } ) 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 ca301e11..27080a07 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -50,8 +51,8 @@ import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.layoutPadding import java.util.Calendar @@ -279,12 +280,12 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { @Composable private fun ReportDialog(statusName: String, day: SatDay, reports: Map, onDismiss: () -> Unit) { val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } - SharedDialog(title = "$statusName · ${day.dateLabel}", onCancel = onDismiss, onAccept = onDismiss) { + ConfirmDialog(title = "$statusName · ${day.dateLabel}", onCancel = onDismiss, onAccept = onDismiss) { if (dayReports.isEmpty()) { Text(stringResource(id = R.string.amsat_no_reports)) Spacer(modifier = Modifier.height(8.dp)) } else { - LazyColumn { + 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) { From 2f4e3f58029c95dbdd18e3cd9b4fcbfe92c02d78 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Wed, 12 Aug 2026 13:00:43 +0200 Subject: [PATCH 14/18] Added the ability to fully customize data sources via import --- .../core/data/repository/DatabaseRepo.kt | 19 +- .../core/data/repository/SettingsRepo.kt | 24 +- .../core/data/repository/DatabaseRepoTest.kt | 12 +- .../core/data/repository/SelectionRepoTest.kt | 2 +- .../look4sat/core/domain/model/Settings.kt | 6 +- .../look4sat/core/domain/source/Sources.kt | 21 +- .../look4sat/core/presentation/Components.kt | 9 +- .../src/main/res/drawable/ic_add.xml | 9 + .../src/main/res/values/strings.xml | 5 +- .../feature/settings/SettingsDialog.kt | 227 ++++++++++-------- .../feature/settings/SettingsScreen.kt | 65 +---- gradle/wrapper/gradle-wrapper.properties | 4 +- 12 files changed, 190 insertions(+), 213 deletions(-) create mode 100644 core/presentation/src/main/res/drawable/ic_add.xml diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index 0c404ad7..3dd53bd5 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -23,7 +23,6 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource -import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.domain.utility.DataParser import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async @@ -40,7 +39,6 @@ class DatabaseRepo( private val settingsRepo: ISettingsRepo ) : IDatabaseRepo { - private val customSourceType = "Other" override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { var importedCount = 0 @@ -65,18 +63,13 @@ class DatabaseRepo( } override suspend fun updateFromRemote() = withContext(dispatcher) { - val dataSourcesSettings = settingsRepo.dataSourcesSettings.value - val tleUrls = buildMap { - putAll(Sources.satelliteDataUrls) - if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl) - }.filterValues { it.isNotBlank() } - val radioUrls = buildMap { - putAll(Sources.transceiversDataUrls) - if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl) - }.filterValues { it.isNotBlank() } + val settings = settingsRepo.dataSourcesSettings.value + fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url" + val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl) + val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl) // launch all network requests concurrently - val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } - val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } + val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } + val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } // parse fetched data concurrently val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() 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 5abc6d63..07542265 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 @@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings 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 @@ -89,11 +90,10 @@ class SettingsRepo( private val keyHighElevation = "highElevation" private val keyRadarCompassOffset = "radarCompassOffset" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" - private val keyUseCustomTle = "useCustomTle" - private val keyUseCustomTransceivers = "useCustomTransceivers" - private val keyTleUrl = "tleUrl" - private val keyTransceiversUrl = "transceiversUrl" + private val keySatelliteUrls = "satelliteUrls" + private val keyTransceiversUrls = "transceiversUrls" private val separatorComma = "," + private val separatorUrl = "\n" //region # Satellites selection settings private val _satelliteSelection = MutableStateFlow(getSelectedIds()) @@ -365,19 +365,19 @@ class SettingsRepo( override fun updateDataSourcesSettings(settings: DataSourcesSettings) { preferences.edit { - putBoolean(keyUseCustomTle, settings.useCustomTLE) - putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers) - putString(keyTleUrl, settings.tleUrl) - putString(keyTransceiversUrl, settings.transceiversUrl) + putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl)) + putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl)) } _dataSourcesSettings.value = settings } private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( - useCustomTLE = preferences.getBoolean(keyUseCustomTle, false), - useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false), - tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "", - transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: "" + satelliteUrls = preferences.getString(keySatelliteUrls, null) + ?.split(separatorUrl)?.filter { it.isNotBlank() } + ?: Sources.satelliteDataUrls, + transceiversUrls = preferences.getString(keyTransceiversUrls, null) + ?.split(separatorUrl)?.filter { it.isNotBlank() } + ?: Sources.transceiversDataUrls ) //endregion 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 e8b051fe..6041173b 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 @@ -90,10 +90,8 @@ class DatabaseRepoTest { } val settingsRepo = FakeSettingsRepo( dataSources = DataSourcesSettings( - useCustomTLE = true, - useCustomTransceivers = false, - tleUrl = customCsvUrl, - transceiversUrl = "" + satelliteUrls = listOf(customCsvUrl), + transceiversUrls = emptyList() ) ) val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) @@ -221,9 +219,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou private fun defaultDataSourcesSettings(): DataSourcesSettings { return DataSourcesSettings( - useCustomTLE = false, - useCustomTransceivers = false, - tleUrl = "", - transceiversUrl = "" + satelliteUrls = emptyList(), + transceiversUrls = emptyList() ) } 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 index 8bb71983..ebd521d1 100644 --- 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 @@ -135,7 +135,7 @@ class SelectionRepoTest { ) override val dataSourcesSettings: StateFlow = MutableStateFlow( - DataSourcesSettings(false, false, "", "") + DataSourcesSettings(emptyList(), emptyList()) ) override val radioControlSettings: StateFlow = MutableStateFlow( 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 281e0c86..9fd68bd3 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 @@ -67,10 +67,8 @@ data class OtherSettings( ) data class DataSourcesSettings( - val useCustomTLE: Boolean, - val useCustomTransceivers: Boolean, - val tleUrl: String, - val transceiversUrl: String + val satelliteUrls: List, + val transceiversUrls: List ) data class RadioControlSettings( 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 77e150a4..6236f072 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 @@ -18,18 +18,17 @@ package com.rtbishop.look4sat.core.domain.source object Sources { - val satelliteDataUrls = mapOf( - "CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", - "AMSAT" to "https://amsat.org/tle/current/nasabare.txt", - "McCants Classified" to "https://www.mmccants.org/tles/classfd.zip", - "McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip", - "R4UAB" to "https://r4uab.ru/satonline.txt", - "SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le", - "ARISS" to "https://live.ariss.org/iss.txt", - "Other" to "" // key for sats filter + val satelliteDataUrls = listOf( + "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", + "db.satnogs.org/api/tle/?format=3le", + "amsat.org/tle/current/nasabare.txt", + "mmccants.org/tles/classfd.zip", + "r4uab.ru/satonline.txt", + "live.ariss.org/iss.txt" ) - val transceiversDataUrls = mapOf( - "SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active" + val transceiversDataUrls = listOf( + "db.satnogs.org/api/transmitters/?format=json&status=active", + "r4uab.ru/transmitters.json" ) val satelliteModes = listOf( "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", 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 b9ffe81e..d8118566 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 @@ -239,8 +239,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) } 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/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 3d6250d2..5ebb70f3 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -172,10 +172,7 @@ No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file. No transceivers imported. Select a valid SatNOGS (.json) file. - Custom sources - TLE URL - Transceivers URL - URL (HTTPS) + Data sources Data output Network 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 43baaf24..38e7418b 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,26 +17,39 @@ */ package com.rtbishop.look4sat.feature.settings +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.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.runtime.Composable import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -46,6 +59,7 @@ import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.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 @@ -129,133 +143,144 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni private fun TransceiversDialogPreview() { MainTheme { DataSourcesDialog( - useCustomTle = true, - useCustomTransceivers = true, - tleUrl = "https://example.com/tle.txt", - transceiversUrl = "https://example.com/tx.json", - requestCustomSourcesPermission = { onGranted, _ -> onGranted() }, + satelliteUrls = listOf( + "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", + "amsat.org/tle/current/nasabare.txt" + ), + transceiversUrls = listOf( + "db.satnogs.org/api/transmitters/?format=json&status=active" + ), onImportTle = {}, onImportTransceivers = {}, onDismiss = {}, - onSave = { _, _, _, _ -> } + onSave = { _, _ -> } ) } } @Composable fun DataSourcesDialog( - useCustomTle: Boolean, - useCustomTransceivers: Boolean, - tleUrl: String, - transceiversUrl: String, - requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit, + satelliteUrls: List, + transceiversUrls: List, onImportTle: () -> Unit, onImportTransceivers: () -> Unit, onDismiss: () -> Unit, - onSave: (Boolean, Boolean, String, String) -> Unit + onSave: (List, List) -> Unit ) { val padding = LocalSpacing.current.large - val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) } - val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) } - val urlTle = rememberSaveable { mutableStateOf(tleUrl) } - val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) } - val onAccept = { - onSave( - isEnabledCustomTle.value, - isEnabledCustomTransceivers.value, - urlTle.value, - urlTransceivers.value - ) - onDismiss() + // Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries). + val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) } + val satUrls = remember { + satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList() } - val onCancel = { onDismiss() } + val txUrls = remember { + transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList() + } + val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() } ConfirmDialog( title = stringResource(id = R.string.prefs_data_sources_title), - onCancel = onCancel, + onCancel = onDismiss, onAccept = onAccept, ) { - Column(modifier = Modifier.padding(horizontal = padding)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - CardButton( - onClick = { - onImportTle() - onDismiss() - }, - text = "TLE/3LE (.txt)\nOMM (.csv)", - modifier = Modifier.weight(1f) - ) - CardButton( - onClick = { - onImportTransceivers() - onDismiss() - }, - text = "Transceivers\nSatNOGS (.json)", - modifier = Modifier.weight(1f) - ) + LazyColumn( + modifier = Modifier + .fillMaxHeight(0.84f) + .padding(horizontal = padding), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 6.dp) + ) { + item { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + CardButton( + onClick = { onImportTle(); onDismiss() }, + text = "TLE/3LE (.txt)\nOMM (.csv)", + modifier = Modifier.weight(1f) + ) + CardButton( + onClick = { onImportTransceivers(); onDismiss() }, + text = "Transceivers\nSatNOGS (.json)", + modifier = Modifier.weight(1f) + ) + } } - Spacer(modifier = Modifier.height(6.dp)) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch)) - Switch( - checked = isEnabledCustomTle.value, - onCheckedChange = { enabled -> - if (!enabled) { - isEnabledCustomTle.value = false - } else { - requestCustomSourcesPermission( - { isEnabledCustomTle.value = true }, - { isEnabledCustomTle.value = false } - ) - } - } - ) - } - OutlinedTextField( - value = urlTle.value, - onValueChange = { urlTle.value = it }, - label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - enabled = isEnabledCustomTle.value, + sourceSection( + sectionKey = "sat", + label = "Satellites data", + urls = satUrls, + onAdd = { satUrls.add(nextId.longValue++ to "") }, + onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) }, + onRemove = { i -> satUrls.removeAt(i) }, + onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v } ) - Spacer(modifier = Modifier.height(6.dp)) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch)) - Switch( - checked = isEnabledCustomTransceivers.value, - onCheckedChange = { enabled -> - if (!enabled) { - isEnabledCustomTransceivers.value = false - } else { - requestCustomSourcesPermission( - { isEnabledCustomTransceivers.value = true }, - { isEnabledCustomTransceivers.value = false } - ) - } - } - ) - } - OutlinedTextField( - value = urlTransceivers.value, - onValueChange = { urlTransceivers.value = it }, - label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - enabled = isEnabledCustomTransceivers.value, + sourceSection( + sectionKey = "tx", + label = "Transceivers data", + urls = txUrls, + onAdd = { txUrls.add(nextId.longValue++ to "") }, + onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) }, + onRemove = { i -> txUrls.removeAt(i) }, + onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v } ) - Spacer(modifier = Modifier.height(12.dp)) } } } +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() { 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 63c3cf0f..3f828b21 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 @@ -64,6 +64,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 @@ -93,22 +94,10 @@ fun SettingsDestination() { @Composable private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) { val dialogs = rememberDialogVisibility() - val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) } - val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) } val permissions = rememberSettingsPermissions( sendAction = onAction, onBluetoothGranted = { dialogs.bluetooth = true }, - onNetworkGranted = { dialogs.network = true }, - onCustomSourcesPermissionGranted = { - pendingCustomSourcesGrant.value?.invoke() - pendingCustomSourcesGrant.value = null - pendingCustomSourcesDeny.value = null - }, - onCustomSourcesPermissionDenied = { - pendingCustomSourcesDeny.value?.invoke() - pendingCustomSourcesGrant.value = null - pendingCustomSourcesDeny.value = null - } + onNetworkGranted = { dialogs.network = true } ) // Dialogs @@ -129,30 +118,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> } if (dialogs.dataSources) { DataSourcesDialog( - useCustomTle = uiState.dataSourcesSettings.useCustomTLE, - useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers, - tleUrl = uiState.dataSourcesSettings.tleUrl, - transceiversUrl = uiState.dataSourcesSettings.transceiversUrl, - requestCustomSourcesPermission = { onGranted, onDenied -> - pendingCustomSourcesGrant.value = onGranted - pendingCustomSourcesDeny.value = onDenied - permissions.launchCustomSourcesPermission() - }, + satelliteUrls = uiState.dataSourcesSettings.satelliteUrls, + transceiversUrls = uiState.dataSourcesSettings.transceiversUrls, 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 - ) - if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings)) - if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) { - onAction(SettingsAction.UpdateFromWeb) - } + onSave = { satUrls, txUrls -> + val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls) + if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings)) + onAction(SettingsAction.UpdateFromWeb) } ) } @@ -752,17 +726,14 @@ private class SettingsPermissions( val launchTleImport: () -> Unit, val launchTransceiverImport: () -> Unit, val launchBluetooth: () -> Unit, - val launchNetwork: () -> Unit, - val launchCustomSourcesPermission: () -> Unit + val launchNetwork: () -> Unit ) @Composable private fun rememberSettingsPermissions( sendAction: (SettingsAction) -> Unit, onBluetoothGranted: () -> Unit, - onNetworkGranted: () -> Unit, - onCustomSourcesPermissionGranted: () -> Unit, - onCustomSourcesPermissionDenied: () -> Unit + onNetworkGranted: () -> Unit ): SettingsPermissions { val locationError = stringResource(R.string.prefs_loc_gps_error) val locationRequest = rememberLauncherForActivityResult( @@ -799,15 +770,6 @@ private fun rememberSettingsPermissions( else sendAction(SettingsAction.ShowToast(networkError)) } - val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { - onCustomSourcesPermissionGranted() - } else { - onCustomSourcesPermissionDenied() - sendAction(SettingsAction.ShowToast(networkError)) - } - } - return remember { SettingsPermissions( launchLocation = { @@ -824,13 +786,6 @@ private fun rememberSettingsPermissions( } else { onNetworkGranted() } - }, - launchCustomSourcesPermission = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { - customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) - } else { - onCustomSourcesPermissionGranted() - } } ) } 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 From 1982c2d3dc3e67d7f2e55e960fb1edcd47087514 Mon Sep 17 00:00:00 2001 From: PingouinFerreux <212632237+PingouinFerreux@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:22:53 +0200 Subject: [PATCH 15/18] Fixed broken star history chart in README (#240) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 78fbd1b8..0e3d8c4b 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ It is now and always will be completely ad-free and open-source. ## Star History - + - - - Star History Chart + + + Star History Chart From bd510446900ba5a64e774cb69d50c11d326cd3bd Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Wed, 12 Aug 2026 20:15:06 +0200 Subject: [PATCH 16/18] Added custom frequency offset setting to network reporting --- .../core/data/framework/NetworkReporter.kt | 91 +++++++++++++------ .../core/data/injection/MainContainer.kt | 3 +- .../core/data/repository/SettingsRepo.kt | 15 ++- .../core/data/repository/DatabaseRepoTest.kt | 6 +- .../core/data/repository/SelectionRepoTest.kt | 2 +- .../look4sat/core/domain/model/Constants.kt | 23 +++++ .../look4sat/core/domain/model/Settings.kt | 1 + .../core/domain/utility/Extensions.kt | 15 +++ .../look4sat/core/domain/DataParserTest.kt | 7 ++ .../src/main/res/values/strings.xml | 2 + .../feature/settings/SettingsDialog.kt | 34 ++++++- .../feature/settings/SettingsScreen.kt | 5 +- 12 files changed, 167 insertions(+), 37 deletions(-) create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt index b116b190..50143f68 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt @@ -17,8 +17,10 @@ */ package com.rtbishop.look4sat.core.data.framework +import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.domain.repository.IReporter import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -31,18 +33,30 @@ class NetworkReporter( private val rotatorServer: String, private val rotatorPort: Int, private val frequencyServer: String, - private val frequencyPort: Int + private val frequencyPort: Int, + private val frequencyOffsetHz: Long = 0L ) : IReporter { private val writeMutex = Mutex() + private val connectionMutex = Mutex() + private val frequencyCommands = Channel(Channel.CONFLATED) private var rotatorSocket: SocketChannel? = null private var rotatorConnected = false - private var rotatorConnecting = false private var frequencySocket: SocketChannel? = null private var frequencyConnected = false - private var frequencyConnecting = false + + init { + // Keep only the latest frequency command to avoid stale backlog and effective lag. + reporterScope.launch { + for (command in frequencyCommands) { + ensureFrequencyConnected() + if (!frequencyConnected) continue + write(frequencySocket, command) { resetFrequencyConnection() } + } + } + } override fun reportRotation(format: String, azimuth: Double, elevation: Double) { reporterScope.launch { @@ -58,46 +72,43 @@ class NetworkReporter( } override fun reportFrequency(format: String, frequency: Long) { - reporterScope.launch { - ensureFrequencyConnected() - if (!frequencyConnected) return@launch - val command = format - .replace($$"$FREQ", frequency.toString()) - .unescapeControlChars() - write(frequencySocket, command) { frequencyConnected = false } - } + val clampedOffset = frequencyOffsetHz.coerceIn( + Constants.FREQ_OFFSET_MIN_HZ, + Constants.FREQ_OFFSET_MAX_HZ + ) + val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L) + val command = format + .replace($$"$FREQ", correctedFreq.toString()) + .unescapeControlChars() + frequencyCommands.trySend(command) } - private fun ensureRotatorConnected() { - if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return - reporterScope.launch { + private suspend fun ensureRotatorConnected() { + connectionMutex.withLock { + if (rotatorConnected || rotatorServer.isBlank()) return try { - rotatorConnecting = true + resetRotatorConnection() rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort)) rotatorConnected = true println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort") } catch (e: Exception) { println("NetworkReporter rotator connect error: ${e.message}") - rotatorConnected = false - } finally { - rotatorConnecting = false + resetRotatorConnection() } } } - private fun ensureFrequencyConnected() { - if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return - reporterScope.launch { + private suspend fun ensureFrequencyConnected() { + connectionMutex.withLock { + if (frequencyConnected || frequencyServer.isBlank()) return try { - frequencyConnecting = true + resetFrequencyConnection() frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort)) frequencyConnected = true println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort") } catch (e: Exception) { println("NetworkReporter frequency connect error: ${e.message}") - frequencyConnected = false - } finally { - frequencyConnecting = false + resetFrequencyConnection() } } } @@ -106,7 +117,9 @@ class NetworkReporter( try { writeMutex.withLock { val buffer = ByteBuffer.wrap("$command\n".toByteArray()) - socket?.write(buffer) + while (buffer.hasRemaining()) { + socket?.write(buffer) + } } } catch (e: Exception) { println("NetworkReporter write error: ${e.message}") @@ -114,6 +127,32 @@ class NetworkReporter( } } + private fun resetRotatorConnection() { + rotatorConnected = false + closeQuietly(rotatorSocket) + rotatorSocket = null + } + + private fun resetFrequencyConnection() { + frequencyConnected = false + closeQuietly(frequencySocket) + frequencySocket = null + } + + private fun closeQuietly(socket: SocketChannel?) { + try { + socket?.close() + } catch (_: Exception) {} + } + + private fun Long.safeAdd(delta: Long): Long { + return when { + delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE + delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE + else -> this + delta + } + } + private fun String.unescapeControlChars(): String = replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t") } 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 34e4a12c..05778db5 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 @@ -106,7 +106,8 @@ class MainContainer(private val context: Context) : IMainContainer { rc.rotatorAddress, rc.rotatorPort.toIntOrNull() ?: 0, rc.frequencyAddress, - rc.frequencyPort.toIntOrNull() ?: 0 + rc.frequencyPort.toIntOrNull() ?: 0, + rc.frequencyOffsetHz ) } 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 07542265..2fbe2cac 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,6 +29,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings +import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.source.Sources @@ -69,6 +70,7 @@ class SettingsRepo( private val keyFrequencyAddress = "frequencyAddress" private val keyFrequencyPort = "frequencyPort" private val keyFrequencyFormat = "frequencyFormat" + private val keyFrequencyOffsetHz = "frequencyOffsetHz" private val keySelectedIds = "selectedIds" private val keySelectedSatModes = "selectedSatModes" private val keyStateOfAutoUpdate = "stateOfAutoUpdate" @@ -277,6 +279,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) @@ -286,6 +292,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) @@ -294,7 +301,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( @@ -306,6 +313,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", @@ -425,7 +434,7 @@ class SettingsRepo( val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}" return try { JSONObject(json).optString(catnum.toString(), "") - } catch (e: Exception) { + } catch (_: Exception) { "" } } @@ -436,7 +445,7 @@ class SettingsRepo( val obj = JSONObject(json) if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset) obj.toString() - } catch (e: Exception) { + } catch (_: Exception) { """{"$catnum": "$offset"}""" } preferences.edit { putString(keySatelliteOffsets, updated) } 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 6041173b..5a8a1a9c 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 @@ -120,6 +120,10 @@ private class FakeRemoteSource : IRemoteSource { override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() + + override suspend fun getAmSatCatalog(): String? = null + + override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null } private class FakeLocalSource : ILocalSource { @@ -172,7 +176,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( 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 index ebd521d1..aa0292da 100644 --- 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 @@ -127,7 +127,7 @@ class SelectionRepoTest { 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( 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/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index 9fd68bd3..97e5018a 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 @@ -41,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, 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 5d7a5588..9e767cf7 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 @@ -239,4 +240,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/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 5ebb70f3..5d2b8c44 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -196,6 +196,8 @@ Enable frequency output IP:Port Format + Frequency offset (Hz) + Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it. Bluetooth output Enable rotation output 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 38e7418b..41ff4791 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 @@ -39,6 +39,7 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -58,6 +59,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings +import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.LocalSpacing @@ -295,6 +297,7 @@ fun PreviewNetworkOutputDialog() { frequencyAddress = "127.0.0.1", frequencyPort = "4532", frequencyFormat = $$"F $FREQ", + frequencyOffsetHz = 0L, bluetoothRotatorState = false, bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorName = "Default", @@ -304,7 +307,7 @@ fun PreviewNetworkOutputDialog() { bluetoothFrequencyFormat = $$"F $FREQ" ), onDismiss = {}, - onSave = { _, _, _, _, _, _, _, _ -> } + onSave = { _, _, _, _, _, _, _, _, _ -> } ) } } @@ -315,7 +318,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 @@ -329,12 +332,15 @@ 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() } @@ -367,6 +373,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)) } } @@ -395,6 +422,7 @@ fun PreviewBluetoothOutputDialog() { frequencyAddress = "127.0.0.1", frequencyPort = "4532", frequencyFormat = $$"F $FREQ", + frequencyOffsetHz = 0L, bluetoothRotatorState = false, bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorName = "Default", 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 3f828b21..356392e8 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 @@ -134,14 +134,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> 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 ) ) ) From 7cdc2952dc8e40bc8063df32364a83b7eea97101 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Thu, 13 Aug 2026 09:18:09 +0200 Subject: [PATCH 17/18] v4.4.6 - AMSAT status page, fully customizable data sources --- .../java/com/rtbishop/look4sat/MainScreen.kt | 12 ++++++------ .../src/main/res/values/strings.xml | 17 +++++++---------- .../android/en-US/changelogs/default.txt | 15 +++++++-------- .../look4sat/feature/status/SatStatusScreen.kt | 4 ++-- gradle/libs.versions.toml | 4 ++-- 5 files changed, 24 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index c4345b1a..48e36371 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -93,11 +93,11 @@ fun NavRoot(deeplink: String? = null) { val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) } // Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style) - val pushTransition = slideInHorizontally(tween(350)) { it } togetherWith - slideOutHorizontally(tween(350)) { -it / 3 } + 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(350)) { -it / 3 } togetherWith - slideOutHorizontally(tween(350)) { it } + val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith + slideOutHorizontally(tween(300)) { it } NavDisplay( modifier = Modifier.fillMaxSize(), backStack = rootBackStack, @@ -133,8 +133,8 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) { val backStack = rememberNavBackStack(Screen.Passes) val currentKey = backStack.lastOrNull() val navigateBack: () -> Unit = { backStack.removeLastOrNull() } - val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith - fadeOut(animationSpec = tween(350)) + val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings) diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 5d2b8c44..a9b6cf80 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -56,16 +56,13 @@ \n\nPlease update the database at least weekly to get accurate predictions. What\'s new in Look4Sat - * Added small tweaks to Sources and strings. Added official ISS source (#225) - \n* Implemented AOS window and elevation highlight filters (#226) - \n* Added cleartext traffic support for custom TLE URLs (#227) - \n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228) - \n* Added support for Icom IC-705 CAT by ruilvo (#229) - \n* Added configurable Radar offset to the sensors output by qwqtoday (#230) - \n* Localized pass date and time formats for Chinese by atsunatsu (#231) - \n* Added linear transponder Doppler calculator by atsunatsu (#232) - \n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36 - \n* Replaced the types selection dialog with modes selection to avoid confusion + * Added AMSAT status tracking page by MCKero6423 (#234) + \n* Tweaked Radar track on reposition by atsunatsu (#235) + \n* Tweaked linear transponder calc by atsunatsu (#236) + \n* Added saving per-sat doppler offset by atsunatsu (#237) + \n* Added custom freq offset setting to network reporting + \n* Added ability to customize data sources via import + \n* Fixed star chart in README by PingouinFerreux (#240) diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt index 6997002f..88bd5615 100644 --- a/fastlane/metadata/android/en-US/changelogs/default.txt +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -1,8 +1,7 @@ -* Added ARISS source, tweaked SSTV sensitivity #225 -* Implemented AOS window and elev filter #226 -* Added cleartext traffic support for TLEs #227 -* Fixed OMM import, tweaked error messages #228 -* Added support for Icom CAT by ruilvo #229 -* Added tweakable Radar offset by qwqtoday #230 -* Localized date/time formats by atsunatsu #231 -* Added linear radio Doppler calc by atsunatsu #232 +* 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/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 27080a07..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 @@ -51,7 +51,7 @@ import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.repository.IContainerProvider -import com.rtbishop.look4sat.core.presentation.ConfirmDialog +import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.layoutPadding import java.util.Calendar @@ -280,7 +280,7 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { @Composable private fun ReportDialog(statusName: String, day: SatDay, reports: Map, onDismiss: () -> Unit) { val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } - ConfirmDialog(title = "$statusName · ${day.dateLabel}", onCancel = onDismiss, onAccept = onDismiss) { + 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)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f63642f..705d9dea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "445" +appVersionCode = "446" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.5" +appVersionName = "4.4.6" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry From a42a5f1f0d5ed7dfdda724d666090f20369bc4fa Mon Sep 17 00:00:00 2001 From: atsunatsu <1017079775@qq.com> Date: Wed, 19 Aug 2026 20:46:39 +0800 Subject: [PATCH 18/18] Tweaked sunrise/sunset calculations, added unit tests (#241) Co-authored-by: atsunatsu --- .../core/domain/predict/CelestialComputer.kt | 8 +- .../domain/predict/CelestialComputerTest.kt | 79 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt 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/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt new file mode 100644 index 00000000..6685e85c --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt @@ -0,0 +1,79 @@ +/* + * 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 + +class CelestialComputerTest { + + private data class RiseSetCase( + val name: String, + val observer: GeoPos, + val startIso: String + ) + + @Test + fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() { + val cases = listOf( + RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"), + RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"), + RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"), + RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"), + RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"), + RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z") + ) + + cases.forEach { testCase -> + val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis()) + val daylightDuration = result.setTimeMillis - result.riseTimeMillis + + assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L) + assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L) + assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis) + assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS) + assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS) + + val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation + val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation + assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02) + assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02) + } + } + + @Test + fun `findSunRiseSet does not return the same instant for equinox regression cases`() { + listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso -> + val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis()) + val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis) + + assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS) + } + } + + private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli() + + private companion object { + private const val SUNRISE_SET_THRESHOLD = -0.8333 + private const val HOUR_MILLIS = 60L * 60L * 1000L + private const val DAY_MILLIS = 24L * HOUR_MILLIS + } +}