feat(settings): 致谢与声明卡、版本卡打开检查更新、浅色主题、品牌与 GPL 头

This commit is contained in:
atsunatsu committed 2026-09-28 23:46:52 +08:00
1 parent 5acf71b2a6
commit 45cd72faf7
23 files changed
+410 -100

No files matched your search

@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/*
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
*
@@ -139,6 +139,18 @@ class AwardBoundaryOverlay : Overlay() {
color = Color.argb(90, 76, 217, 100)
}
/**
* Recolor the amber boundary/label paints for the active map theme (dark amber + white halo on
* the light map, light amber + black halo on the dark map). The worked fill reads on both.
*/
fun applyTheme(isLightUi: Boolean) {
val line = if (isLightUi) Color.argb(200, 113, 92, 12) else Color.argb(160, 255, 224, 130)
val label = if (isLightUi) Color.argb(235, 30, 25, 5) else Color.argb(220, 255, 224, 130)
linePaint.color = line
labelPaint.color = label
labelPaint.setShadowLayer(3f, 2f, 2f, if (isLightUi) Color.WHITE else Color.BLACK)
}
/** Boundary regions to render; assigning recomputes culling bounds. */
var regions: List<AwardRegion> = emptyList()
set(value) {
@@ -89,6 +89,24 @@ class MaidenheadGridOverlay : Overlay() {
color = Color.argb(255, 76, 217, 100)
}
/**
* Recolor the amber line/label paints for the active map theme: dark amber with a white halo
* on the light map, light amber with a black halo on the dark map. The worked / marked / roam /
* selected colors stay as they are — they read on both backgrounds.
*/
fun applyTheme(isLightUi: Boolean) {
val line = if (isLightUi) Color.argb(200, 113, 92, 12) else Color.argb(160, 255, 224, 130)
val label = if (isLightUi) Color.argb(235, 30, 25, 5) else Color.argb(220, 255, 224, 130)
val halo = if (isLightUi) Color.WHITE else Color.BLACK
linePaint.color = line
// ownLine stays the same color as the normal grid lines (only thicker).
ownLinePaint.color = line
labelPaint.color = label
labelPaint.setShadowLayer(3f, 2f, 2f, halo)
firstCallPaint.color = label
firstCallPaint.setShadowLayer(3f, 2f, 2f, halo)
}
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.map
import androidx.compose.runtime.MutableState
@@ -159,6 +159,71 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
}
/**
* Accent used for the station marker, footprint outline and map labels. It is recolored per
* theme (dark amber on light tiles, light amber on dark tiles) so every map element stays
* readable; [applyMapColors] keeps it in sync with the shared paints.
*/
private var mapAccentColor = "#FFE082".toColorInt()
/** Grayscale + invert filter that turns the offline OSM map dark; unused (and off) in light theme. */
private val darkTileFilter by lazy { createColorFilter() }
/** Theme the shared map paints were last colored for; null = not applied yet this process. */
private var mapIsLightUi: Boolean? = null
/** Theme the tile filter / icon caches were last applied for (drives the per-frame theme check). */
private var appliedLightUi: Boolean? = null
/** True while the offline OSM source is active — satellite imagery (Tianditu) is never filtered. */
private var offlineTilesActive = true
/**
* Recolor the shared overlay paints and custom overlays for the current theme. Must run before the
* markers are (re)built each frame so freshly created icons use the new colors; [applyThemeToOverlays]
* and the icon-cache eviction in the update block handle the already created ones.
*/
private fun applyMapColors(isLightUi: Boolean) {
mapIsLightUi = isLightUi
if (isLightUi) {
// Dark amber (lightScheme primary) reads on light tiles; labels get a white halo.
mapAccentColor = "#715C0C".toColorInt()
footprintPaint.color = mapAccentColor
textPaint.color = "#1E1B13".toColorInt()
textPaint.setShadowLayer(3f, 3f, 3f, Color.WHITE)
sunIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
moonIconPaint.colorFilter = android.graphics.PorterDuffColorFilter(
"#4C4639".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN
)
} else {
mapAccentColor = "#FFE082".toColorInt()
footprintPaint.color = mapAccentColor
textPaint.color = mapAccentColor
textPaint.setShadowLayer(3f, 3f, 3f, Color.BLACK)
sunIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
moonIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
}
applyThemeToOverlays()
}
/** Push the current theme into the grid / award overlays, which own their own paints. */
private fun applyThemeToOverlays() {
val isLight = mapIsLightUi == true
currentMapView?.overlays?.forEach { overlay ->
when (overlay) {
is MaidenheadGridOverlay -> overlay.applyTheme(isLight)
is AwardBoundaryOverlay -> overlay.applyTheme(isLight)
else -> Unit
}
}
}
/** The MapView currently attached to the map page; used to recolor overlays on a theme change. */
private var currentMapView: MapView? = null
@Composable
fun MapDestination(
mapFilterViewModel: MapFilterViewModel,
@@ -320,8 +385,8 @@ private fun MapScreen(
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
}
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
LaunchedEffect(uiState.mapSource, uiState.tiandituKey, uiState.isLightUi) {
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey, uiState.isLightUi)
}
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val isVertical = isVerticalLayout()
@@ -359,6 +424,18 @@ private fun MapScreen(
mutableStateOf(restoredViewport && uiState.isGridMode)
}
AndroidView({ mapView }) { view ->
// Theme change: recolor the paints/tile filter/overlays and drop the icon
// caches, which baked the previous theme's colors into their bitmaps.
if (appliedLightUi != uiState.isLightUi) {
appliedLightUi = uiState.isLightUi
applyMapColors(uiState.isLightUi)
iconCache.evictAll()
dotIcon = null
view.overlayManager.tilesOverlay.setColorFilter(
if (!uiState.isLightUi && offlineTilesActive) darkTileFilter else null
)
requestTileRefresh(view)
}
// Award filter mode: show the selected award's regions with
// worked ones filled green; hide satellite layers like grid mode.
val awardMode = selectedAward?.takeIf { it != AwardType.VUCC }
@@ -393,13 +470,13 @@ private fun MapScreen(
}
}
if (!uiState.isGridMode) {
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.stationPosition?.let { setStationPosition(it, view, uiState.isLightUi) }
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view, uiState.isLightUi)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view, uiState.isLightUi)
}
view.invalidate()
}
@@ -634,7 +711,7 @@ private fun MarkedStationRow(
text = marked.call,
style = MaterialTheme.typography.titleMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = ComposeColor(0xFFFFE082),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
@@ -651,7 +728,7 @@ private fun MarkedStationRow(
Icon(
painter = painterResource(R.drawable.ic_pin),
contentDescription = stringResource(R.string.grid_mark_pin),
tint = if (isPinned) ComposeColor(0xFF9E9E9E) else ComposeColor(0xFFFFE082),
tint = if (isPinned) ComposeColor(0xFF9E9E9E) else MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(18.dp)
.clickable(onClick = onPinMark)
@@ -706,7 +783,7 @@ private fun WorkedGridCallRow(
text = call,
style = MaterialTheme.typography.titleMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = ComposeColor(0xFFFFE082),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
@@ -1092,22 +1169,29 @@ private fun FirstCallToggle(
// region Tile sources
private var configuredTileMapView: MapView? = null
private var configuredTileSource: String? = null
private var configuredTileIsLightUi: Boolean? = null
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String, isLightUi: Boolean) {
val key = tiandituKey.trim()
val layers = tiandituLayers(mapSource)
val sourceKey = if (layers == null || key.isBlank()) MapSource.OSM else "${layers.base}:$key"
if (configuredTileMapView === mapView && configuredTileSource == sourceKey) return
val offlineSource = layers == null || key.isBlank()
offlineTilesActive = offlineSource
val sourceKey = if (offlineSource) MapSource.OSM else "${layers.base}:$key"
if (configuredTileMapView === mapView && configuredTileSource == sourceKey &&
configuredTileIsLightUi == isLightUi
) return
configuredTileMapView = mapView
configuredTileSource = sourceKey
configuredTileIsLightUi = isLightUi
(mapView.overlays.getOrNull(OVERLAY_TDT_LABELS) as? TilesOverlay)?.onDetach(mapView)
if (layers == null || key.isBlank()) {
if (offlineSource) {
mapView.setUseDataConnection(false)
mapView.setTileSource(offlineTileSource)
mapView.maxZoomLevel = OFFLINE_MAX_ZOOM
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(createColorFilter())
// The dark-map filter belongs to the dark theme only; the light theme shows plain tiles.
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(if (isLightUi) null else darkTileFilter)
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
requestTileRefresh(mapView)
return
@@ -1210,6 +1294,7 @@ private fun setAwardMode(award: AwardType, workedCodes: Set<String>, mapView: Ma
isEnabled = true
regions = AwardBoundaryData.load(mapView.context, asset)
this.workedCodes = workedCodes
applyTheme(mapIsLightUi == true)
}
}
for (index in OVERLAY_STATION..OVERLAY_MOON) {
@@ -1252,6 +1337,7 @@ private fun setGridMode(
this.firstCallsByGrid = firstCallsByGrid
this.markedGrids = markedGrids
this.ownGrid = ownGridOf(stationPosition)
applyTheme(mapIsLightUi == true)
}
}
// Satellite-related layers are hidden in grid mode; the grid overlay
@@ -1306,17 +1392,28 @@ private fun gridOfPoint(latitude: Double, longitude: Double): String? {
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
}
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private fun setStationPosition(stationPos: GeoPos, mapView: MapView, isLightUi: Boolean) {
try {
val overlay = mapView.overlays[OVERLAY_STATION]
val stationPoint = GeoPoint(stationPos.latitude, stationPos.longitude)
if (overlay is Marker) {
overlay.position = GeoPoint(stationPos.latitude, stationPos.longitude)
overlay.position = stationPoint
// Icon tint is baked into the drawable, so re-tint after a theme change.
if ((overlay.relatedObject as? Boolean) != isLightUi) {
overlay.icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
setTint(mapAccentColor)
}
overlay.relatedObject = isLightUi
}
} else {
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
position = GeoPoint(stationPos.latitude, stationPos.longitude)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
setTint(mapAccentColor)
}
position = stationPoint
relatedObject = isLightUi
}
}
} catch (e: Exception) {
@@ -1539,26 +1636,37 @@ private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView
}
}
/** Build the 48dp sun/moon marker icon, tinted with the theme-dependent paint. */
private fun buildMarkerIcon(mapView: MapView, resId: Int, paint: Paint): Drawable {
val iconSize = 48
val bmp = createBitmap(iconSize, iconSize)
ContextCompat.getDrawable(mapView.context, resId)?.apply {
setBounds(0, 0, iconSize, iconSize)
colorFilter = paint.colorFilter
draw(Canvas(bmp))
}
return bmp.toDrawable(mapView.context.resources)
}
/** Place an ic_sun icon marker at the sub-solar point. */
private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView, isLightUi: Boolean) {
try {
val overlay = mapView.overlays[OVERLAY_SUN]
val sunPos = GeoPoint(sunLatDeg, sunLonDeg)
if (overlay is Marker) {
overlay.position = sunPos
} else {
val iconSize = 48
val bmp = createBitmap(iconSize, iconSize)
ContextCompat.getDrawable(mapView.context, R.drawable.ic_sun)?.apply {
setBounds(0, 0, iconSize, iconSize)
colorFilter = sunIconPaint.colorFilter
draw(Canvas(bmp))
// The icon bitmap bakes in the theme tint, so rebuild it after a theme change.
if ((overlay.relatedObject as? Boolean) != isLightUi) {
overlay.icon = buildMarkerIcon(mapView, R.drawable.ic_sun, sunIconPaint)
overlay.relatedObject = isLightUi
}
} else {
mapView.overlays[OVERLAY_SUN] = Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = bmp.toDrawable(mapView.context.resources)
icon = buildMarkerIcon(mapView, R.drawable.ic_sun, sunIconPaint)
position = sunPos
relatedObject = isLightUi
}
}
} catch (e: Exception) {
@@ -1567,26 +1675,23 @@ private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapV
}
/** Place an ic_moon icon marker at the sub-lunar point. */
private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: MapView) {
private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: MapView, isLightUi: Boolean) {
try {
val overlay = mapView.overlays[OVERLAY_MOON]
val moonPos = GeoPoint(moonLatDeg, moonLonDeg)
if (overlay is Marker) {
overlay.position = moonPos
} else {
val iconSize = 48
val bmp = createBitmap(iconSize, iconSize)
val c = Canvas(bmp)
ContextCompat.getDrawable(mapView.context, R.drawable.ic_moon)?.apply {
setBounds(0, 0, iconSize, iconSize)
colorFilter = moonIconPaint.colorFilter
draw(c)
if ((overlay.relatedObject as? Boolean) != isLightUi) {
overlay.icon = buildMarkerIcon(mapView, R.drawable.ic_moon, moonIconPaint)
overlay.relatedObject = isLightUi
}
} else {
mapView.overlays[OVERLAY_MOON] = Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = bmp.toDrawable(mapView.context.resources)
icon = buildMarkerIcon(mapView, R.drawable.ic_moon, moonIconPaint)
position = moonPos
relatedObject = isLightUi
}
}
} catch (e: Exception) {
@@ -1631,10 +1736,12 @@ private fun rememberMapViewWithLifecycle(
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
// The dark-map tile filter (or no filter in the light theme) is applied per theme from
// the AndroidView update block, so it also follows a theme switch at runtime.
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
overlays[OVERLAY_GRID] = MaidenheadGridOverlay()
overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply { applyTheme(mapIsLightUi == true) }
currentMapView = this
}
}
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
@@ -1647,7 +1754,10 @@ private fun rememberMapViewWithLifecycle(
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
// (The viewport save lives in MapDestination, where the grid/satellite mode is known.)
DisposableEffect(mapView) {
onDispose { clearMapCaches() }
onDispose {
clearMapCaches()
if (currentMapView === mapView) currentMapView = null
}
}
return mapView
}
@@ -84,6 +84,7 @@ class MapViewModel(
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
isLightUi = settings.stateOfLightTheme,
mapSource = settings.mapSource,
tiandituKey = settings.tiandituKey,
isGridMode = settings.stateOfMapGrid,
@@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -264,11 +265,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (dialogs.logbook) {
LogbookDialog(
records = uiState.logbookRecords,
satelliteCandidates = uiState.satelliteCatalog,
uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage,
preview = uiState.logbookPreview,
onDismiss = { dialogs.logbook = false },
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) },
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
@@ -292,10 +295,8 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
// URLs for top bar
val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url)
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
val upstreamUrl = stringResource(R.string.prefs_upstream_url)
val gitHubTitle = stringResource(R.string.prefs_github_title)
val gitHubUrl = stringResource(R.string.prefs_github_url)
val licenseUrl = stringResource(R.string.prefs_license_url)
@@ -309,18 +310,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (isVerticalLayout) {
TopBar {
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { showUpdateChecker = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
}
TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(
onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
onClick = { safeOpenUri(upstreamUrl) },
resId = R.drawable.ic_github,
text = upstreamTitle,
modifier = Modifier.weight(1f)
)
BotCard(
@@ -335,17 +335,16 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
} else {
TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { showUpdateChecker = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(
onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
onClick = { safeOpenUri(upstreamUrl) },
resId = R.drawable.ic_github,
text = upstreamTitle,
modifier = Modifier.weight(1f)
)
BotCard(
@@ -639,7 +638,7 @@ private fun OtherCard(
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(370.dp)
.height(420.dp) // 370dp + one switch row (light theme)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -666,7 +665,16 @@ private fun OtherCard(
text = stringResource(R.string.prefs_compass_calibration_button),
modifier = Modifier.fillMaxWidth()
)
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
// Appearance: light theme + the red night filter (mutually exclusive —
// the night filter is disabled while the light theme is on).
SwitchRow(R.string.prefs_other_switch_light_theme, settings.stateOfLightTheme) {
onAction(SettingsAction.ToggleLightTheme(it))
}
SwitchRow(
R.string.prefs_other_switch_night_mode,
settings.stateOfNightMode,
enabled = !settings.stateOfLightTheme
) {
onAction(SettingsAction.ToggleNightMode(it))
}
}
@@ -762,14 +770,19 @@ private fun CompassCalibrationDialog(
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
private fun SwitchRow(
labelResId: Int,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = labelResId))
Switch(checked = checked, onCheckedChange = onCheckedChange)
Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
}
}
@@ -845,27 +858,28 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
@Composable
private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.height(268.dp)
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxHeight()
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
) {
Text(
text = stringResource(id = R.string.prefs_outro_title),
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(id = R.string.prefs_outro_thanks)
text = stringResource(id = R.string.prefs_outro_notice),
fontSize = 12.sp
)
HorizontalDivider()
Text(
text = stringResource(id = R.string.prefs_outro_thanks),
fontSize = 12.sp
)
Text(
text = stringResource(id = R.string.prefs_outro_license),
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary,
fontSize = 12.sp
)
}
}
@@ -70,6 +70,8 @@ data class SettingsState(
val lotwLastSyncEpochMs: Long = 0L,
/** Logbook (QSO records + LoTW confirmations), newest first. */
val logbookRecords: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord> = emptyList(),
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
val satelliteCatalog: List<String> = emptyList(),
/** Imported LoTW upload certificate (null when none). */
val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null,
/** LoTW upload station location (null when unset). */
@@ -153,6 +155,8 @@ sealed interface SettingsAction {
// Logbook (QSO records + LoTW confirmations)
data object RefreshLogbook : SettingsAction
data class DeleteLogbookRecord(val id: Long) : SettingsAction
/** Persist an edited record (frequency/callsign/time/…) from the logbook dialog. */
data class UpdateLogbookRecord(val record: com.rtbishop.look4sat.core.domain.logbook.QsoRecord) : SettingsAction
data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction