From 0ecccc474641cb1c6b62f2e0b6c64b5760086103 Mon Sep 17 00:00:00 2001 From: QIU Date: Mon, 3 Aug 2026 14:29:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(roaming):=20port=20QTH=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=99=A8=202.0=20page=20and=20logic=20verbatim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roaming page was repeatedly rebuilt by hand and the red marker still rendered at the wrong spot on the user's device. Per the user's explicit instruction the whole page is now a faithful, line-by-line port of the reference app (QTH定位器 2.0, com.us1pm.gridsquarelocator) with zero UI or logic changes. UI (res/layout/main.xml, byte-verified via aapt2 dump): - 25dp holo-blue GPS bar: "GPS" 14sp, green/red status dot 15dp (original mipmaps copied as drawables), centered date, right time, translucent-yellow "设置启用GPS" button that opens location settings - Latitude/longitude rows: 16sp black labels, right-aligned decimal values, DMS label format "纬度 22° 18' 50" N" exactly as reference - 43sp bold black locator, centered, with progress spinner + notice - 3x3 continuous grid: 60/200/60dp columns, middle row fixed 205dp, edge cells #0BACF1, center cell 200x200dp holo-blue (#01DDFF as shown on the user's device), center label 100x80dp 30sp bold white (textColorHighlight) centered, bottom "制作:US1PM 汉化:BA7LCE" - Red marker: original pnt.png (red square with white outline), 10x10dp, absolutely positioned by the reference lookup tables (lon 3rd pair a..x -> leftMargin -2..190dp, lat 3rd pair a..x -> topMargin 190..-2dp, screen-Y inverted) Logic (MainActivity.java showLocation/checkEnabled/onResume): - 8-char locator via the reference range-lookup tables - 3x3 neighbor grid via parseInt3 five-branch logic incl. all four corner-carry tables (00/09/99/90) - Live GPS + network updates 10s/10m while the page is shown, provider filtered to gps/network, checkEnabled three-state (green dot / red dot + settings button) exactly like the reference - Time = cached hour prefix + fix minutes; date "dd MMM yyyy" Dropped only the Play-store ad banner (conflicts with GPL project). Verified: 11 unit tests pass (locator, marker lookup, grid, DMS, time, all edge branches); feature + app modules compile. RoamingState.kt removed - state and math now live in RoamingScreen.kt. --- .../look4sat/feature/roaming/RoamingScreen.kt | 946 +++++++++++++----- .../look4sat/feature/roaming/RoamingState.kt | 78 -- .../src/main/res/drawable/roam_gps_green.png | Bin 0 -> 176 bytes .../src/main/res/drawable/roam_gps_red.png | Bin 0 -> 172 bytes .../src/main/res/drawable/roam_pnt.png | Bin 0 -> 375 bytes .../feature/roaming/RoamingStateTest.kt | 124 +++ 6 files changed, 818 insertions(+), 330 deletions(-) delete mode 100644 feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingState.kt create mode 100644 feature/roaming/src/main/res/drawable/roam_gps_green.png create mode 100644 feature/roaming/src/main/res/drawable/roam_gps_red.png create mode 100644 feature/roaming/src/main/res/drawable/roam_pnt.png create mode 100644 feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt index 7c7738a9..1585559b 100644 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt @@ -17,13 +17,23 @@ */ package com.rtbishop.look4sat.feature.roaming +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -34,304 +44,736 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Divider -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf +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.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.painterResource 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 com.rtbishop.look4sat.core.domain.repository.IContainerProvider -import com.rtbishop.look4sat.core.presentation.LocalSpacing -import com.rtbishop.look4sat.core.presentation.ScreenColumn -import com.rtbishop.look4sat.core.presentation.TopBar +import androidx.core.content.ContextCompat import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** - * Roaming (漫游) page: a faithful port of the QTH定位器 location panel — - * GPS status + date/time header, DMS & decimal coordinates, the big 8-char - * Maidenhead locator, and a 3x3 grid of neighboring squares (center column - * and row are wider/taller, matching the reference app). Styled with the - * Look4Sat Material 3 theme. - * - * Coordinates are read straight from the shared settingsRepo.stationPosition - * StateFlow — the same source the Settings page uses — so the page shows - * exactly the station position (站位), whatever the GPS says there. No - * automatic location polling on this page. + * 漫游页 —— 原样照搬自 QTH定位器 2.0 (com.us1pm.gridsquarelocator)。 + * UI: res/layout/main.xml (结构/尺寸/颜色逐项照搬, 不做任何更改) + * 逻辑: MainActivity.showLocation() / checkEnabled() / GPS 实时监听 + * 红线: UI 与逻辑均不更改, 与成品截图逐像素对齐。 */ @Composable fun RoamingScreen() { val context = LocalContext.current - val container = (context.applicationContext as IContainerProvider).getMainContainer() - val stationPos by container.settingsRepo.stationPosition.collectAsStateWithLifecycle() - - val state = remember(stationPos) { - RoamingState.fromPosition(stationPos) - } - - // Clock ticker: re-render date/time every second so the header clock - // actually moves (the reference app refreshes it on every location fix). - var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + var state by remember { mutableStateOf(RoamingState()) } + // 照搬 onCreate: 日期固定文本 + 小时前缀(定位时拼接分钟) + val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) } + var hourPrefix by remember { mutableStateOf("") } + // 照搬 onCreate: 初始 checkEnabled() -> 红点+设置按钮, 日期隐藏 + var hasPermission by remember { mutableStateOf(false) } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { } LaunchedEffect(Unit) { - while (true) { - now = System.currentTimeMillis() - kotlinx.coroutines.delay(1_000L) + hourPrefix = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 3) + val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + hasPermission = fine || coarse + if (!hasPermission) { + permissionLauncher.launch( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + ) + } else { + // 照搬 onResume: 绿点+进度条+提示+日期显示, 红点/按钮隐藏 + state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true) } } + // 照搬 onResume/onPause: GPS + network 监听, 10 秒 / 10 米 + val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager } + DisposableEffect(locationManager, hasPermission) { + if (hasPermission) { + @SuppressLint("MissingPermission") + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + // 照搬 showLocation 开头: 只接受 gps / network provider + if (location.provider != "gps" && location.provider != "network") return + state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix) + .copy(date = dateText) + } - ScreenColumn( - topBar = { - TopBar { Text(text = "漫游", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + override fun onProviderDisabled(provider: String) { + // 照搬 checkEnabled: 红点+按钮, 日期隐藏 + state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false) + } + + override fun onProviderEnabled(provider: String) { + state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true) + } + + @Deprecated("Deprecated in Java") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + } + try { + locationManager.requestLocationUpdates("gps", 10_000L, 10f, listener) + locationManager.requestLocationUpdates("network", 10_000L, 10f, listener) + } catch (_: SecurityException) { + } + onDispose { locationManager.removeUpdates(listener) } + } else { + onDispose { } } - ) { - val spacing = LocalSpacing.current - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(spacing.medium) + } + // 照搬 res/layout/main.xml: 浅色页面, 蓝色信息区, 43sp 定位码, 3x3 九宫格, 底部版权 + Column(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { + // relativeLayout7: 顶部 GPS 状态条, 25dp 高, holo_blue_bright + Box( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + .height(25.dp) + .background(Color(0xFF01DDFF)) ) { - InfoCard(state = state, now = now) - GridCard(state = state, modifier = Modifier.weight(1f)) + Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + // tvTitleGPS: "GPS" 14sp 黑, 左 8dp + Text( + text = "GPS", + fontSize = 14.sp, + color = Color.Black, + modifier = Modifier.padding(start = 8.dp) + ) + // imGps: toRightOf tvTitleGPS + marginLeft 15dp (成品 main.xml) + Spacer(modifier = Modifier.width(15.dp)) + // imGps: 绿点 (15dp) + if (state.gpsOn) { + Image( + painter = painterResource(R.drawable.roam_gps_green), + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + } + // imGpsOff: 红点叠层 (15dp) + if (state.gpsOff) { + Image( + painter = painterResource(R.drawable.roam_gps_red), + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + } + Spacer(modifier = Modifier.weight(1f)) + // tvDate: 14sp 黑, 居中 + if (state.showDate) { + Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = Color.Black) + } + Spacer(modifier = Modifier.weight(1f)) + // tvTime: 14sp 黑, 右对齐 + Text( + text = state.time, + fontSize = 14.sp, + color = Color.Black, + modifier = Modifier.padding(end = 8.dp) + ) + } + // btnLocationSettings: 黄色按钮, 居中 + // btnLocationSettings: 成品 #faf8f54a 半透明黄, 14sp 黑字, 居中 + if (state.showSettings) { + Box( + modifier = Modifier + .align(Alignment.Center) + .background(Color(0xFAF8F54A)) + .clickable { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } + .padding(horizontal = 5.dp), + contentAlignment = Alignment.Center + ) { + Text(text = "设置启用GPS", fontSize = 14.sp, color = Color.Black) + } + } } - } -} - -@Composable -private fun InfoCard(state: RoamingState, now: Long) { - val colorScheme = MaterialTheme.colorScheme - Column( - modifier = Modifier - .fillMaxWidth() - .background(colorScheme.secondaryContainer) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Top bar: GPS status + date + time - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "GPS", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = colorScheme.onSecondaryContainer - ) - Spacer(modifier = Modifier.width(6.dp)) - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(if (state.gpsEnabled) colorScheme.primary else colorScheme.outline) - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date(now)), - fontSize = 14.sp, - color = colorScheme.onSecondaryContainer - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(now)), - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = colorScheme.onSecondaryContainer - ) + // linearLayout4: 经纬度区, holo_blue_bright + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp) + .background(Color(0xFF01DDFF)) + ) { + // 纬度行: tvLati 16sp 黑 左 8dp | tvLat 右对齐 3dp + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Text(text = state.latLabel, fontSize = 16.sp, color = Color.Black, modifier = Modifier.padding(start = 8.dp)) + Spacer(modifier = Modifier.weight(1f)) + Text(text = state.latValue, fontSize = 14.sp, color = Color.Black, modifier = Modifier.padding(end = 3.dp)) + } + // 经度行 + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Text(text = state.lonLabel, fontSize = 16.sp, color = Color.Black, modifier = Modifier.padding(start = 8.dp)) + Spacer(modifier = Modifier.weight(1f)) + Text(text = state.lonValue, fontSize = 14.sp, color = Color.Black, modifier = Modifier.padding(end = 3.dp)) + } } - // Coordinates: DMS + decimal - CoordinateRow(label = "纬度", value = state.latitude, isLat = true) - CoordinateRow(label = "经度", value = state.longitude, isLat = false) - // Big locator, centered + // relativeLayout6: 定位码区, 43sp bold 黑居中 + Box( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + .background(Color(0xFF01DDFF)) + ) { + Text( + text = state.loc, + fontSize = 43.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + // progressBar: 加载中, 居中 + if (state.showProgress) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).align(Alignment.Center), + color = Color.Black, + strokeWidth = 2.dp + ) + } + // tvNotice: 提示文字, 居中顶部 + if (state.showNotice) { + Text( + text = "请稍等,加载可能需要几分钟的时间。", + fontSize = 16.sp, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + color = Color.Black, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp) + ) + } + } + // relativeLayout: 九宫格, 占满剩余, 底部 15dp + Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) { + // 行1: relativeLayout5 (上排: 纬度 +1) + Row(modifier = Modifier.fillMaxWidth().weight(1f)) { + GridCell(text = state.grids[0], modifier = Modifier.width(60.dp).fillMaxHeight()) + GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight()) + GridCell(text = state.grids[2], modifier = Modifier.width(60.dp).fillMaxHeight()) + } + // 行2: relativeLayout4, 205dp 高 (中排: 中央) + Row(modifier = Modifier.fillMaxWidth().height(205.dp)) { + GridCell(text = state.grids[3], modifier = Modifier.width(60.dp).fillMaxHeight()) + // 中央列: tv22 (100x80dp centerInParent) 叠在 linearLayout3 (200x200dp) 之上 + Box(modifier = Modifier.width(200.dp).fillMaxHeight()) { + // linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = 2.dp) + .size(width = 200.dp, height = 200.dp) + .background(Color(0xFF01DDFF)) + ) { + // iv: 红点 10x10dp, top|left, leftMargin/topMargin 查表绝对定位 + Image( + painter = painterResource(R.drawable.roam_pnt), + contentDescription = null, + modifier = Modifier + .size(10.dp) + .offset(x = state.markerLeft.dp, y = state.markerTop.dp) + ) + } + // tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight(白) + Text( + text = state.grids[4], + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .size(width = 100.dp, height = 80.dp) + .align(Alignment.Center) + ) + } + GridCell(text = state.grids[5], modifier = Modifier.width(60.dp).fillMaxHeight()) + } + // 行3: relativeLayout3 (下排: 纬度 -1) + Row(modifier = Modifier.fillMaxWidth().weight(1f)) { + GridCell(text = state.grids[6], modifier = Modifier.width(60.dp).fillMaxHeight()) + GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight()) + GridCell(text = state.grids[8], modifier = Modifier.width(60.dp).fillMaxHeight()) + } + } + // tv4: 底部版权, 10sp 黑, 15dp 高 Text( - text = state.qthLocator, - fontSize = 34.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSecondaryContainer, + text = "制作:US1PM 汉化:BA7LCE", + fontSize = 10.sp, + color = Color.Black, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().height(15.dp) ) } } +// 周边格: 60/200/60dp, #0BACF1, bold 居中, 2dp margin (照搬 tv11/tv13/tv21/tv23/tv31/tv33) @Composable -private fun CoordinateRow(label: String, value: Double, isLat: Boolean) { - val colorScheme = MaterialTheme.colorScheme - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { +private fun GridCell(text: String, modifier: Modifier) { + Box( + modifier = modifier + .padding(2.dp) + .background(Color(0xFF0BACF1)), + contentAlignment = Alignment.Center + ) { Text( - text = label, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface, - modifier = Modifier.width(40.dp) - ) - Text(text = formatDms(value, isLat), fontSize = 15.sp, color = colorScheme.onSurface) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "${formatDecimal(value)}°", + text = text, fontSize = 14.sp, - color = colorScheme.onSurfaceVariant + fontWeight = FontWeight.Bold, + color = Color.Black, + textAlign = TextAlign.Center ) } } /** - * 3x3 grid of 4-char squares — a faithful continuous-table port: no outer - * card, no rounded cells, no gaps. Cells are connected edge-to-edge and - * separated by thin divider lines (crossing at right angles like a real - * coordinate grid). Column widths 21.4% : 56.2% : 21.4%, row heights - * 31.5% : 35.9% : 31.7% mirror the reference app: the center column is - * ~2.6x wider and the center row is the tallest, so the current square - * dominates the panel. + * 照搬 MainActivity.showLocation() 的状态。 + * 网格/九宫格/红点查表与成品逐行一致。 */ -@Composable -private fun GridCard(state: RoamingState, modifier: Modifier = Modifier) { - val colorScheme = MaterialTheme.colorScheme - val dividerColor = colorScheme.outline - Column( - modifier = modifier - .fillMaxSize() - .border(2.dp, dividerColor) - ) { - GridRow(state, row = 0, weight = 0.315f, dividerColor = dividerColor) - Divider(color = dividerColor, thickness = 2.dp) - GridRow(state, row = 1, weight = 0.359f, dividerColor = dividerColor) - Divider(color = dividerColor, thickness = 2.dp) - GridRow(state, row = 2, weight = 0.317f, dividerColor = dividerColor) +data class RoamingState( + val date: String = "", + val time: String = "", + val latLabel: String = "纬度", + val latValue: String = "", + val lonLabel: String = "经度", + val lonValue: String = "", + val loc: String = "", + val grids: List = List(9) { "" }, + /** 红点 leftMargin(dp), 经度第 3 对字符查表 */ + val markerLeft: Int = 0, + /** 红点 topMargin(dp), 纬度第 3 对字符查表(屏幕 Y 反转) */ + val markerTop: Int = 0, + val gpsOn: Boolean = false, + val gpsOff: Boolean = true, + val showSettings: Boolean = true, + val showProgress: Boolean = false, + val showNotice: Boolean = false, + val showDate: Boolean = false +) + +/** + * 照搬 showLocation() 的完整计算: 时间/经纬度 DMS/8 位网格/九宫格/红点查表。 + */ +fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState { + // 照搬: tvTime = 缓存小时前缀 + 定位时间分钟 + val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime)) + val time = hourPrefix + minute + // 方向字符 + val ew = if (lon >= 0.0) "E" else "W" + val ns = if (lat >= 0.0) "N" else "S" + // 照搬: 纬度 DMS (String.valueOf 无补零) + val latAbs = if (lat < 0.0) -lat else lat + val latDeg = latAbs.toInt() + val latMinFull = (latAbs - latDeg) * 60.0 + val latMin = latMinFull.toInt() + val latSec = ((latMinFull - latMin) * 60.0).toInt() + val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns" + val latValue = "$lat° " + // 照搬: 经度 DMS + val lonAbs = if (lon < 0.0) -lon else lon + val lonDeg = lonAbs.toInt() + val lonMinFull = (lonAbs - lonDeg) * 60.0 + val lonMin = lonMinFull.toInt() + val lonSec = ((lonMinFull - lonMin) * 60.0).toInt() + val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew" + val lonValue = "$lon° " + // 照搬: 8 位网格编码 (区间查表) + val (str, str2, str3, str4) = encodeLon(lon) + val (str5, str6, str38, str9) = encodeLat(lat) + val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9 + // 照搬: 九宫格 parseInt3 五分支 + val grids = buildGrids(str, str5, str2, str6, str3, str38) + // 照搬: 红点查表 (第 3 对字符) + val markerLeft = lonMargin[str3.firstOrNull()] ?: 0 + val markerTop = latMargin[str38.firstOrNull()] ?: 0 + return RoamingState( + time = time, + latLabel = latLabel, + latValue = latValue, + lonLabel = lonLabel, + lonValue = lonValue, + loc = loc, + grids = grids, + markerLeft = markerLeft, + markerTop = markerTop, + gpsOn = true, + gpsOff = false, + showSettings = false, + showProgress = false, + showNotice = false, + showDate = true + ) +} + +// 红点 leftMargin 查表 (MainActivity 1286-1386 行): 经度第 3 对 a..x -> -2..190 dp +private val lonMargin = mapOf( + 'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56, + 'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128, + 'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190 +) + +// 红点 topMargin 查表 (MainActivity 1315-1363 行): 纬度第 3 对 a..x -> 190..-2 dp (屏幕 Y 反转) +private val latMargin = mapOf( + 'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137, + 'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65, + 'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2 +) + +// 照搬 311-611 行: 经度 -> (20°区字母, 2°数字, 2'字母, 30"数字) +private fun encodeLon(inputLon: Double): List { + var longitude = inputLon + var d = 0.0 + val str = when { + (longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" } + (longitude >= -160.0) && (longitude <= -140.0) -> { longitude += 160.0; "B" } + (longitude >= -140.0) && (longitude <= -120.0) -> { longitude += 140.0; "C" } + (longitude >= -120.0) && (longitude <= -100.0) -> { longitude += 120.0; "D" } + (longitude >= -100.0) && (longitude <= -80.0) -> { longitude += 100.0; "E" } + (longitude >= -80.0) && (longitude <= -60.0) -> { longitude += 80.0; "F" } + (longitude >= -60.0) && (longitude <= -40.0) -> { longitude += 60.0; "G" } + (longitude >= -40.0) && (longitude <= -20.0) -> { longitude += 40.0; "H" } + (longitude >= -20.0) && (longitude <= 0.0) -> { longitude += 20.0; "I" } + (longitude >= 0.0) && (longitude <= 20.0) -> "J" + (longitude >= 20.0) && (longitude <= 40.0) -> { longitude -= 20.0; "K" } + (longitude >= 40.0) && (longitude <= 60.0) -> { longitude -= 40.0; "L" } + (longitude >= 60.0) && (longitude <= 80.0) -> { longitude -= 60.0; "M" } + (longitude >= 80.0) && (longitude <= 100.0) -> { longitude -= 80.0; "N" } + (longitude >= 100.0) && (longitude <= 120.0) -> { longitude -= 100.0; "O" } + (longitude >= 120.0) && (longitude <= 140.0) -> { longitude -= 120.0; "P" } + (longitude >= 140.0) && (longitude <= 160.0) -> { longitude -= 140.0; "Q" } + (longitude >= 160.0) && (longitude <= 180.0) -> { longitude -= 160.0; "R" } + else -> { longitude = 0.0; " " } } -} - -@Composable -private fun ColumnScope.GridRow( - state: RoamingState, - row: Int, - weight: Float, - dividerColor: Color -) { - Row( - modifier = Modifier - .fillMaxWidth() - .weight(weight) - ) { - GridCell( - label = state.gridSquares.getOrElse(row * 3 + 0) { "----" }, - isCenter = false, - dividerColor = dividerColor, - modifier = Modifier.weight(0.214f) - ) - Divider( - color = dividerColor, thickness = 2.dp, - modifier = Modifier.fillMaxHeight().width(2.dp) - ) - GridCell( - label = state.gridSquares.getOrElse(row * 3 + 1) { "----" }, - isCenter = row == 1, - markerX = state.markerX, - markerY = state.markerY, - showMarker = row == 1 && state.qthLocator != "----", - dividerColor = dividerColor, - modifier = Modifier.weight(0.562f) - ) - Divider( - color = dividerColor, thickness = 2.dp, - modifier = Modifier.fillMaxHeight().width(2.dp) - ) - GridCell( - label = state.gridSquares.getOrElse(row * 3 + 2) { "----" }, - isCenter = false, - dividerColor = dividerColor, - modifier = Modifier.weight(0.214f) - ) + val str2 = when { + ((longitude >= 0.0) && (longitude <= 2.0)) || ((longitude >= -20.0) && (longitude <= -18.0)) -> { d = longitude * 60.0; "0" } + ((longitude >= 2.0) && (longitude <= 4.0)) || ((longitude >= -18.0) && (longitude <= -16.0)) -> { d = (longitude - 2.0) * 60.0; "1" } + ((longitude >= 4.0) && (longitude <= 6.0)) || ((longitude >= -16.0) && (longitude <= -14.0)) -> { d = (longitude - 4.0) * 60.0; "2" } + ((longitude >= 6.0) && (longitude <= 8.0)) || ((longitude >= -14.0) && (longitude <= -12.0)) -> { d = (longitude - 6.0) * 60.0; "3" } + ((longitude >= 8.0) && (longitude <= 10.0)) || ((longitude >= -12.0) && (longitude <= -10.0)) -> { d = (longitude - 8.0) * 60.0; "4" } + ((longitude >= 10.0) && (longitude <= 12.0)) || ((longitude >= -10.0) && (longitude <= -8.0)) -> { d = (longitude - 10.0) * 60.0; "5" } + ((longitude >= 12.0) && (longitude <= 14.0)) || ((longitude >= -8.0) && (longitude <= -6.0)) -> { d = (longitude - 12.0) * 60.0; "6" } + ((longitude >= 14.0) && (longitude <= 16.0)) || ((longitude >= -6.0) && (longitude <= -4.0)) -> { d = (longitude - 14.0) * 60.0; "7" } + ((longitude >= 16.0) && (longitude <= 18.0)) || ((longitude >= -4.0) && (longitude <= -2.0)) -> { d = (longitude - 16.0) * 60.0; "8" } + ((longitude >= 18.0) && (longitude <= 20.0)) || ((longitude >= -2.0) && (longitude <= 0.0)) -> { d = (longitude - 18.0) * 60.0; "9" } + else -> { d = 0.0; " " } } -} - -@Composable -private fun GridCell( - label: String, - isCenter: Boolean, - markerX: Float = 0.5f, - markerY: Float = 0.5f, - showMarker: Boolean = false, - dividerColor: Color, - modifier: Modifier = Modifier -) { - val colorScheme = MaterialTheme.colorScheme - val cellBackground = if (isCenter) colorScheme.secondaryContainer else colorScheme.surfaceVariant - val labelColor = if (isCenter) colorScheme.onSecondaryContainer else colorScheme.onSurfaceVariant - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .background(cellBackground) - ) { - // Capture constraints at the BoxWithConstraints scope (Column scope would shadow them) - val width = maxWidth - val height = maxHeight - if (isCenter) { - // Center cell, faithful to the reference app: the locator label - // sits at the upper-middle and the red marker is positioned by - // (markerX, markerY) — both derived from the 3rd character pair - // via the reference lookup tables (lon a=-2..x=190, lat a=190..x=-2 - // as topMargin, i.e. latitude inverted on screen). The marker - // therefore tracks the fix inside the 4-char square in BOTH axes. - val density = LocalDensity.current - val dotSize = 14.dp - // Marker top-left from the cell's top-left corner: - // x = markerX * cellWidth, y = markerY * cellHeight (screen Y). - if (showMarker) { - val markerOffsetX = with(density) { (width * markerX).toPx().toDp() - dotSize / 2 } - val markerOffsetY = with(density) { (height * markerY).toPx().toDp() - dotSize / 2 } - Box( - modifier = Modifier - .size(dotSize) - .background(colorScheme.error) - .offset(x = markerOffsetX, y = markerOffsetY) - ) - } - // Label above the marker, upper-middle of the cell - Text( - text = label, - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = labelColor, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 8.dp) - ) - } else { - Text( - text = label, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = labelColor, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center) - ) - } + val str3 = when { + ((d >= 0.0) && (d <= 5.0)) || ((d >= -120.0) && (d <= -115.0)) -> { longitude = d * 60.0; "a" } + ((d >= 5.0) && (d <= 10.0)) || ((d >= -115.0) && (d <= -110.0)) -> { longitude = (d - 5.0) * 60.0; "b" } + ((d >= 10.0) && (d <= 15.0)) || ((d >= -110.0) && (d <= -105.0)) -> { longitude = (d - 10.0) * 60.0; "c" } + ((d >= 15.0) && (d <= 20.0)) || ((d >= -105.0) && (d <= -100.0)) -> { longitude = (d - 15.0) * 60.0; "d" } + ((d >= 20.0) && (d <= 25.0)) || ((d >= -100.0) && (d <= -95.0)) -> { longitude = (d - 20.0) * 60.0; "e" } + ((d >= 25.0) && (d <= 30.0)) || ((d >= -95.0) && (d <= -90.0)) -> { longitude = (d - 25.0) * 60.0; "f" } + ((d >= 30.0) && (d <= 35.0)) || ((d >= -90.0) && (d <= -85.0)) -> { longitude = (d - 30.0) * 60.0; "g" } + ((d >= 35.0) && (d <= 40.0)) || ((d >= -85.0) && (d <= -80.0)) -> { longitude = (d - 35.0) * 60.0; "h" } + ((d >= 40.0) && (d <= 45.0)) || ((d >= -80.0) && (d <= -75.0)) -> { longitude = (d - 40.0) * 60.0; "i" } + ((d >= 45.0) && (d <= 50.0)) || ((d >= -75.0) && (d <= -70.0)) -> { longitude = (d - 45.0) * 60.0; "j" } + ((d >= 50.0) && (d <= 55.0)) || ((d >= -70.0) && (d <= -65.0)) -> { longitude = (d - 50.0) * 60.0; "k" } + ((d >= 55.0) && (d <= 60.0)) || ((d >= -65.0) && (d <= -60.0)) -> { longitude = (d - 55.0) * 60.0; "l" } + ((d >= 60.0) && (d <= 65.0)) || ((d >= -60.0) && (d <= -55.0)) -> { longitude = (d - 60.0) * 60.0; "m" } + ((d >= 65.0) && (d <= 70.0)) || ((d >= -55.0) && (d <= -50.0)) -> { longitude = (d - 65.0) * 60.0; "n" } + ((d >= 70.0) && (d <= 75.0)) || ((d >= -50.0) && (d <= -45.0)) -> { longitude = (d - 70.0) * 60.0; "o" } + ((d >= 75.0) && (d <= 80.0)) || ((d >= -45.0) && (d <= -40.0)) -> { longitude = (d - 75.0) * 60.0; "p" } + ((d >= 80.0) && (d <= 85.0)) || ((d >= -40.0) && (d <= -35.0)) -> { longitude = (d - 80.0) * 60.0; "q" } + ((d >= 85.0) && (d <= 90.0)) || ((d >= -35.0) && (d <= -30.0)) -> { longitude = (d - 85.0) * 60.0; "r" } + ((d >= 90.0) && (d <= 95.0)) || ((d >= -30.0) && (d <= -25.0)) -> { longitude = (d - 90.0) * 60.0; "s" } + ((d >= 95.0) && (d <= 100.0)) || ((d >= -25.0) && (d <= -20.0)) -> { longitude = (d - 95.0) * 60.0; "t" } + ((d >= 100.0) && (d <= 105.0)) || ((d >= -20.0) && (d <= -15.0)) -> { longitude = (d - 100.0) * 60.0; "u" } + ((d >= 105.0) && (d <= 110.0)) || ((d >= -15.0) && (d <= -10.0)) -> { longitude = (d - 105.0) * 60.0; "v" } + ((d >= 110.0) && (d <= 115.0)) || ((d >= -10.0) && (d <= -5.0)) -> { longitude = (d - 110.0) * 60.0; "w" } + ((d >= 115.0) && (d <= 120.0)) || ((d >= -5.0) && (d <= 0.0)) -> { longitude = (d - 115.0) * 60.0; "x" } + else -> { longitude = 0.0; " " } } + val str4 = when { + ((longitude >= 0.0) && (longitude <= 30.0)) || ((longitude >= -300.0) && (longitude <= -270.0)) -> "0" + ((longitude >= 30.0) && (longitude <= 60.0)) || ((longitude >= -270.0) && (longitude <= -240.0)) -> "1" + ((longitude >= 60.0) && (longitude <= 90.0)) || ((longitude >= -240.0) && (longitude <= -210.0)) -> "2" + ((longitude >= 90.0) && (longitude <= 120.0)) || ((longitude >= -210.0) && (longitude <= -180.0)) -> "3" + ((longitude >= 120.0) && (longitude <= 150.0)) || ((longitude >= -180.0) && (longitude <= -150.0)) -> "4" + ((longitude >= 150.0) && (longitude <= 180.0)) || ((longitude >= -150.0) && (longitude <= -120.0)) -> "5" + ((longitude >= 180.0) && (longitude <= 210.0)) || ((longitude >= -120.0) && (longitude <= -90.0)) -> "6" + ((longitude >= 210.0) && (longitude <= 240.0)) || ((longitude >= -90.0) && (longitude <= -60.0)) -> "7" + ((longitude >= 240.0) && (longitude <= 270.0)) || ((longitude >= -60.0) && (longitude <= -30.0)) -> "8" + ((longitude >= 270.0) && (longitude <= 300.0)) || ((longitude >= -30.0) && (longitude <= 0.0)) -> "9" + else -> " " + } + return listOf(str, str2, str3, str4) } -private fun formatDms(value: Double, isLat: Boolean): String { - val abs = kotlin.math.abs(value) - val deg = abs.toInt() - val minFull = (abs - deg) * 60 - val min = minFull.toInt() - val sec = ((minFull - min) * 60).toInt() - val hemi = if (value >= 0) (if (isLat) "N" else "E") else (if (isLat) "S" else "W") - return "$deg° $min' $sec\" $hemi" +// 照搬 612-914 行: 纬度 -> (10°区字母, 1°数字, 1'字母, 15"数字) +private fun encodeLat(inputLat: Double): List { + var latitude = inputLat + var d2 = 0.0 + var d3 = 0.0 + val str5 = when { + (latitude >= -90.0) && (latitude <= -80.0) -> { d2 = latitude + 90.0; "A" } + (latitude >= -80.0) && (latitude <= -70.0) -> { d2 = latitude + 80.0; "B" } + (latitude >= -70.0) && (latitude <= -60.0) -> { d2 = latitude + 70.0; "C" } + (latitude >= -60.0) && (latitude <= -50.0) -> { d2 = latitude + 60.0; "D" } + (latitude >= -50.0) && (latitude <= -40.0) -> { d2 = latitude + 50.0; "E" } + (latitude >= -40.0) && (latitude <= -30.0) -> { d2 = latitude + 40.0; "F" } + (latitude >= -30.0) && (latitude <= -20.0) -> { d2 = latitude + 30.0; "G" } + (latitude >= -20.0) && (latitude <= -10.0) -> { d2 = latitude + 20.0; "H" } + (latitude >= -10.0) && (latitude <= 0.0) -> { d2 = latitude + 10.0; "I" } + (latitude >= 0.0) && (latitude <= 10.0) -> { d2 = latitude; "J" } + (latitude >= 10.0) && (latitude <= 20.0) -> { d2 = latitude - 10.0; "K" } + (latitude >= 20.0) && (latitude <= 30.0) -> { d2 = latitude - 20.0; "L" } + (latitude >= 30.0) && (latitude <= 40.0) -> { d2 = latitude - 30.0; "M" } + (latitude >= 40.0) && (latitude <= 50.0) -> { d2 = latitude - 40.0; "N" } + (latitude >= 50.0) && (latitude <= 60.0) -> { d2 = latitude - 50.0; "O" } + (latitude >= 60.0) && (latitude <= 70.0) -> { d2 = latitude - 60.0; "P" } + (latitude >= 70.0) && (latitude <= 80.0) -> { d2 = latitude - 70.0; "Q" } + (latitude >= 80.0) && (latitude <= 90.0) -> { d2 = latitude - 80.0; "R" } + else -> { d2 = 0.0; " " } + } + var str6 = " " + when { + ((d2 >= 0.0) && (d2 <= 1.0)) || ((d2 >= -10.0) && (d2 <= -9.0)) -> { d3 = d2 * 60.0; str6 = "0" } + ((d2 >= 1.0) && (d2 <= 2.0)) || ((d2 >= -9.0) && (d2 <= -8.0)) -> { d3 = (d2 - 1.0) * 60.0; str6 = "1" } + ((d2 >= 2.0) && (d2 <= 3.0)) || ((d2 >= -8.0) && (d2 <= -7.0)) -> { d3 = (d2 - 2.0) * 60.0; str6 = "2" } + ((d2 >= 3.0) && (d2 <= 4.0)) || ((d2 >= -7.0) && (d2 <= -6.0)) -> { d3 = (d2 - 3.0) * 60.0; str6 = "3" } + ((d2 >= 4.0) && (d2 <= 5.0)) || ((d2 >= -6.0) && (d2 <= -5.0)) -> { d3 = (d2 - 4.0) * 60.0; str6 = "4" } + ((d2 >= 5.0) && (d2 <= 6.0)) || ((d2 >= -5.0) && (d2 <= -4.0)) -> { d3 = (d2 - 5.0) * 60.0; str6 = "5" } + ((d2 >= 6.0) && (d2 <= 7.0)) || ((d2 >= -4.0) && (d2 <= -3.0)) -> { d3 = (d2 - 6.0) * 60.0; str6 = "6" } + ((d2 >= 7.0) && (d2 <= 8.0)) || ((d2 >= -3.0) && (d2 <= -2.0)) -> { d3 = (d2 - 7.0) * 60.0; str6 = "7" } + ((d2 >= 8.0) && (d2 <= 9.0)) || ((d2 >= -2.0) && (d2 <= -1.0)) -> { d3 = (d2 - 8.0) * 60.0; str6 = "8" } + ((d2 >= 9.0) && (d2 <= 10.0)) || ((d2 >= -1.0) && (d2 <= 0.0)) -> { d3 = (d2 - 9.0) * 60.0; str6 = "9" } + } + val str38 = when { + ((d3 >= 0.0) && (d3 <= 2.5)) || ((d3 >= -60.0) && (d3 <= -57.5)) -> { d2 = d3 * 60.0; "a" } + ((d3 >= 2.5) && (d3 <= 5.0)) || ((d3 >= -57.5) && (d3 <= -55.0)) -> { d2 = (d3 - 2.5) * 60.0; "b" } + ((d3 >= 5.0) && (d3 <= 7.5)) || ((d3 >= -55.0) && (d3 <= -52.5)) -> { d2 = (d3 - 5.0) * 60.0; "c" } + ((d3 >= 7.5) && (d3 <= 10.0)) || ((d3 >= -52.5) && (d3 <= -50.0)) -> { d2 = (d3 - 7.5) * 60.0; "d" } + ((d3 >= 10.0) && (d3 <= 12.5)) || ((d3 >= -50.0) && (d3 <= -47.5)) -> { d2 = (d3 - 10.0) * 60.0; "e" } + ((d3 >= 12.5) && (d3 <= 15.0)) || ((d3 >= -47.5) && (d3 <= -45.0)) -> { d2 = (d3 - 12.5) * 60.0; "f" } + ((d3 >= 15.0) && (d3 <= 17.5)) || ((d3 >= -45.0) && (d3 <= -42.5)) -> { d2 = (d3 - 15.0) * 60.0; "g" } + ((d3 >= 17.5) && (d3 <= 20.0)) || ((d3 >= -42.5) && (d3 <= -40.0)) -> { d2 = (d3 - 17.5) * 60.0; "h" } + ((d3 >= 20.0) && (d3 <= 22.5)) || ((d3 >= -40.0) && (d3 <= -37.5)) -> { d2 = (d3 - 20.0) * 60.0; "i" } + ((d3 >= 22.5) && (d3 <= 25.0)) || ((d3 >= -37.5) && (d3 <= -35.0)) -> { d2 = (d3 - 22.5) * 60.0; "j" } + ((d3 >= 25.0) && (d3 <= 27.5)) || ((d3 >= -35.0) && (d3 <= -32.5)) -> { d2 = (d3 - 25.0) * 60.0; "k" } + ((d3 >= 27.5) && (d3 <= 30.0)) || ((d3 >= -32.5) && (d3 <= -30.0)) -> { d2 = (d3 - 27.5) * 60.0; "l" } + ((d3 >= 30.0) && (d3 <= 32.5)) || ((d3 >= -30.0) && (d3 <= -27.5)) -> { d2 = (d3 - 30.0) * 60.0; "m" } + ((d3 >= 32.5) && (d3 <= 35.0)) || ((d3 >= -27.5) && (d3 <= -25.0)) -> { d2 = (d3 - 32.5) * 60.0; "n" } + ((d3 >= 35.0) && (d3 <= 37.5)) || ((d3 >= -25.0) && (d3 <= -22.5)) -> { d2 = (d3 - 35.0) * 60.0; "o" } + ((d3 >= 37.5) && (d3 <= 40.0)) || ((d3 >= -22.5) && (d3 <= -20.0)) -> { d2 = (d3 - 37.5) * 60.0; "p" } + ((d3 >= 40.0) && (d3 <= 42.5)) || ((d3 >= -20.0) && (d3 <= -17.5)) -> { d2 = (d3 - 40.0) * 60.0; "q" } + ((d3 >= 42.5) && (d3 <= 45.0)) || ((d3 >= -17.5) && (d3 <= -15.0)) -> { d2 = (d3 - 42.5) * 60.0; "r" } + ((d3 >= 45.0) && (d3 <= 47.5)) || ((d3 >= -15.0) && (d3 <= -12.5)) -> { d2 = (d3 - 45.0) * 60.0; "s" } + ((d3 >= 47.5) && (d3 <= 50.0)) || ((d3 >= -12.5) && (d3 <= -10.0)) -> { d2 = (d3 - 47.5) * 60.0; "t" } + ((d3 >= 50.0) && (d3 <= 52.5)) || ((d3 >= -10.0) && (d3 <= -7.5)) -> { d2 = (d3 - 50.0) * 60.0; "u" } + ((d3 >= 52.5) && (d3 <= 55.0)) || ((d3 >= -7.5) && (d3 <= -5.0)) -> { d2 = (d3 - 52.5) * 60.0; "v" } + ((d3 >= 55.0) && (d3 <= 57.5)) || ((d3 >= -5.0) && (d3 <= -2.5)) -> { d2 = (d3 - 55.0) * 60.0; "w" } + ((d3 >= 57.5) && (d3 <= 60.0)) || ((d3 >= -2.5) && (d3 <= 0.0)) -> { d2 = (d3 - 57.5) * 60.0; "x" } + else -> { d2 = 0.0; " " } + } + val str9 = when { + ((d2 >= 0.0) && (d2 <= 15.0)) || ((d2 >= -150.0) && (d2 <= -135.0)) -> "0" + ((d2 >= 15.0) && (d2 <= 30.0)) || ((d2 >= -135.0) && (d2 <= -120.0)) -> "1" + ((d2 >= 30.0) && (d2 <= 45.0)) || ((d2 >= -120.0) && (d2 <= -105.0)) -> "2" + ((d2 >= 45.0) && (d2 <= 60.0)) || ((d2 >= -105.0) && (d2 <= -90.0)) -> "3" + ((d2 >= 60.0) && (d2 <= 75.0)) || ((d2 >= -90.0) && (d2 <= -75.0)) -> "4" + ((d2 >= 75.0) && (d2 <= 90.0)) || ((d2 >= -75.0) && (d2 <= -60.0)) -> "5" + ((d2 >= 90.0) && (d2 <= 105.0)) || ((d2 >= -60.0) && (d2 <= -45.0)) -> "6" + ((d2 >= 105.0) && (d2 <= 120.0)) || ((d2 >= -45.0) && (d2 <= -30.0)) -> "7" + ((d2 >= 120.0) && (d2 <= 135.0)) || ((d2 >= -30.0) && (d2 <= -15.0)) -> "8" + ((d2 >= 135.0) && (d2 <= 150.0)) || ((d2 >= -15.0) && (d2 <= 0.0)) -> "9" + else -> " " + } + return listOf(str5, str6, str38, str9) } -private fun formatDecimal(value: Double): String = String.format("%.5f", value) +/** + * 照搬 917-1283 行: 九宫格 parseInt3 五分支(内部/西界/东界/北界/南界 + 四角)。 + * 返回 9 格文本: [上左, 上中, 上右, 左中, 中央, 右中, 下左, 下中, 下右]。 + */ +private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List { + val parseInt = str2.toInt() + val parseInt2 = str6.toInt() + val str39 = str + str5 + val str40 = str2 + str6 + val parseInt3 = str40.toInt() + val str41 = str39 + str40 + val g = MutableList(9) { "" } + fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString() + if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) { + // 内部: 数字对 ±1 + val i7 = parseInt3 + 1 + val i8 = i7 - 10 + val valueOf9 = fmt2(i8) + val str11 = i7.toString() + val valueOf18 = (i7 + 10).toString() + val i9 = parseInt3 - 10 + val valueOf10 = fmt2(i9) + val valueOf19 = (parseInt3 + 10).toString() + val i10 = parseInt3 - 1 + val i11 = i10 - 10 + val valueOf11 = fmt2(i11) + val str14 = i10.toString() + val str15 = (i10 + 10).toString() + g[0] = str39 + valueOf9 + g[1] = str39 + str11 + g[2] = str39 + valueOf18 + g[3] = str39 + valueOf10 + g[4] = str41 + g[5] = str39 + valueOf19 + g[6] = str39 + valueOf11 + g[7] = str39 + str14 + g[8] = str39 + str15 + } else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) { + // 西边界: 经度 0 -> 9, 经度字母 -1 + val str43 = (str[0] - 1).toString() + str5 + val str44 = "9" + (parseInt2 + 1) + val i12 = parseInt3 + 1 + val valueOf7 = fmt2(i12) + val str12 = (i12 + 10).toString() + val sb4 = "9" + parseInt2 + val valueOf20 = (parseInt3 + 10).toString() + val str46 = "9" + (parseInt2 - 1) + val i13 = parseInt3 - 1 + val valueOf8 = fmt2(i13) + val str15 = (i13 + 10).toString() + g[0] = str43 + str44 + g[1] = str39 + valueOf7 + g[2] = str39 + str12 + g[3] = str43 + sb4 + g[4] = str41 + g[5] = str39 + valueOf20 + g[6] = str43 + str46 + g[7] = str39 + valueOf8 + g[8] = str39 + str15 + } else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) { + // 东边界: 经度 9 -> 0, 经度字母 +1 + val str47 = (str[0] + 1).toString() + str5 + val i14 = parseInt3 + 1 + val valueOf21 = (i14 - 10).toString() + val valueOf22 = i14.toString() + val str48 = "0" + (parseInt2 + 1) + val valueOf23 = (parseInt3 - 10).toString() + val str49 = "0" + parseInt2 + val i15 = parseInt3 - 1 + val valueOf24 = (i15 - 10).toString() + val valueOf25 = i15.toString() + val str15 = "0" + (parseInt2 - 1) + g[0] = str39 + valueOf21 + g[1] = str39 + valueOf22 + g[2] = str47 + str48 + g[3] = str39 + valueOf23 + g[4] = str41 + g[5] = str47 + str49 + g[6] = str39 + valueOf24 + g[7] = str39 + valueOf25 + g[8] = str47 + str15 + } else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) { + // 北边界: 纬度 9 -> 0, 纬度字母 +1 + val str50 = str + (str5[0] + 1).toString() + val i16 = parseInt3 - 9 + val i17 = i16 - 10 + val valueOf4 = fmt2(i17) + val valueOf26 = i16.toString() + val valueOf27 = (i16 + 10).toString() + val i18 = parseInt3 - 10 + val valueOf5 = fmt2(i18) + val valueOf28 = (parseInt3 + 10).toString() + val i19 = parseInt3 - 1 + val i20 = i19 - 10 + val valueOf6 = fmt2(i20) + val str14 = i19.toString() + val str15 = (i19 + 10).toString() + g[0] = str39 + valueOf4 + g[1] = str50 + valueOf26 + g[2] = str39 + valueOf27 + g[3] = str39 + valueOf5 + g[4] = str41 + g[5] = str39 + valueOf28 + g[6] = str39 + valueOf6 + g[7] = str39 + str14 + g[8] = str39 + str15 + } else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) { + // 南边界: 纬度 0 -> 9, 纬度字母 -1 + val str52 = str + (str5[0] - 1).toString() + val i21 = parseInt3 + 1 + val i22 = i21 - 10 + val valueOf = fmt2(i22) + val valueOf29 = i21.toString() + val valueOf30 = (i21 + 10).toString() + val i23 = parseInt3 - 10 + val valueOf2 = fmt2(i23) + val valueOf31 = (parseInt3 + 10).toString() + val i24 = parseInt3 - 1 + val valueOf3 = fmt2(i24) + val str14 = (i24 + 10).toString() + val str15 = (i24 + 20).toString() + g[0] = str39 + valueOf + g[1] = str39 + valueOf29 + g[2] = str39 + valueOf30 + g[3] = str39 + valueOf2 + g[4] = str41 + g[5] = str39 + valueOf31 + g[6] = str52 + valueOf3 + g[7] = str52 + str14 + g[8] = str52 + str15 + } else if (parseInt3 == 0) { + // 四角 00: 经纬度数字均 -1 进位 + val v32 = (str[0] - 1).toString() + val v33 = (str5[0] - 1).toString() + g[0] = v32 + str5 + "91" + g[1] = str39 + "01" + g[2] = str39 + "11" + g[3] = v32 + str5 + "90" + g[4] = str41 + g[5] = v32 + str5 + "10" + g[6] = v32 + v33 + "99" + g[7] = str + v33 + "09" + g[8] = str + v33 + "19" + } else if (parseInt3 == 9) { + // 四角 09: 经度 -1, 纬度 +1 + val v34 = (str[0] - 1).toString() + val v35 = (str5[0] + 1).toString() + g[0] = v34 + v35 + "90" + g[1] = str + v35 + "00" + g[2] = str + v35 + "10" + g[3] = v34 + str5 + "99" + g[4] = str41 + g[5] = v34 + str5 + "19" + g[6] = v34 + str5 + "98" + g[7] = v34 + str5 + "08" + g[8] = v34 + str5 + "18" + } else if (parseInt3 == 99) { + // 四角 99: 经纬度数字均 +1 进位 + val v36 = (str[0] + 1).toString() + val v37 = (str5[0] + 1).toString() + g[0] = str + v37 + "80" + g[1] = str + v37 + "90" + g[2] = v36 + v37 + "00" + g[3] = str39 + "89" + g[4] = str41 + g[5] = v36 + str5 + "09" + g[6] = str39 + "88" + g[7] = str39 + "98" + g[8] = v36 + str5 + "08" + } else if (parseInt3 == 90) { + // 四角 90: 经度 +1, 纬度 -1 + val v38 = (str[0] + 1).toString() + val v39 = (str5[0] - 1).toString() + g[0] = str39 + "81" + g[1] = str39 + "91" + g[2] = v38 + str5 + "01" + g[3] = str39 + "80" + g[4] = str41 + g[5] = v38 + str5 + "00" + g[6] = str + v39 + "89" + g[7] = str + v39 + "99" + g[8] = v38 + v39 + "09" + } + return g +} diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingState.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingState.kt deleted file mode 100644 index 0cc9de81..00000000 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingState.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Look4Sat. Amateur radio satellite tracker and pass predictor. - * Copyright (C) 2019-2026 Arty Bishop and contributors. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.rtbishop.look4sat.feature.roaming - -import com.rtbishop.look4sat.core.domain.predict.GeoPos -import com.rtbishop.look4sat.core.domain.utility.positionToQth -import com.rtbishop.look4sat.core.domain.utility.qthNeighbors -import com.rtbishop.look4sat.core.domain.utility.qthToSquare - -data class RoamingState( - val latitude: Double = 0.0, - val longitude: Double = 0.0, - val qthLocator: String = "----", - val gridSquares: List = List(9) { "----" }, - /** Fractional position of the red marker inside the center cell, 0..1. */ - val markerX: Float = 0.5f, - val markerY: Float = 0.5f, - /** True when a fresh GPS fix is available (station position updated recently). */ - val gpsEnabled: Boolean = false -) { - companion object { - /** - * Builds the state from a [GeoPos]. The 3x3 grid and the marker - * position are derived from the 8-char locator exactly like the - * QTH定位器 app: the marker is placed by the 3rd character pair - * (e.g. the "ih" in "OL42ih45"), mapped to a 0..1 fraction. - */ - fun fromPosition(pos: GeoPos): RoamingState { - val locator = positionToQth(pos.latitude, pos.longitude) ?: return RoamingState() - val square = qthToSquare(locator) - val squares = if (square != "----") qthNeighbors(square) else List(9) { "----" } - // GPS is simply whatever the station position says — a real fix - // (non-zero, with timestamp) counts as enabled. - val hasFix = pos.timestamp > 0L && (pos.latitude != 0.0 || pos.longitude != 0.0) - return RoamingState( - latitude = pos.latitude, - longitude = pos.longitude, - qthLocator = locator, - gridSquares = squares, - markerX = markerFraction(locator, isLat = false), - markerY = markerFraction(locator, isLat = true), - gpsEnabled = hasFix - ) - } - - /** - * Fractional position of the fix inside the 4-char square, derived from - * the 3rd character pair of an 8-char locator (e.g. the "ih" in - * "OL42ih45"). Ported from the QTH定位器 app: it maps the 3rd-pair - * letters (a..x) to a position inside the center cell instead of - * recomputing from coordinates. - * X: a = west edge .. x = east edge (left -> right). Y: a = south .. - * x = north, inverted so the value grows downward on screen, matching - * the reference app. - */ - private fun markerFraction(locator: String, isLat: Boolean): Float { - if (locator.length < 6) return 0.5f - val index = if (isLat) locator[5] else locator[4] - val fraction = (index.lowercaseChar().code - 97).coerceIn(0, 23) / 24f - return if (isLat) 1f - fraction else fraction - } - } -} diff --git a/feature/roaming/src/main/res/drawable/roam_gps_green.png b/feature/roaming/src/main/res/drawable/roam_gps_green.png new file mode 100644 index 0000000000000000000000000000000000000000..2202aac62e684e6705cf0fefa45191ff6e0e455a GIT binary patch literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE;Axu=U`h{fsTgat+kN7QHT)PMf} z=XLRbBRsCW63eW86ID(+bWb(=-&uIJ^Mi5^3$MyGgP1m>pd<-1XGu*{p1T2RS;vip zb<8wPInvmKnSr3{k^=jIGYfjBv+r+gTf`+?fy1qu4C V$}sM2tOZ)n;OXk;vd$@?2><}gL6iUh literal 0 HcmV?d00001 diff --git a/feature/roaming/src/main/res/drawable/roam_pnt.png b/feature/roaming/src/main/res/drawable/roam_pnt.png new file mode 100644 index 0000000000000000000000000000000000000000..8a1ef8bce24a44a9eebadc1e92fecb3843ed08ed GIT binary patch literal 375 zcmV--0f_#IP)#ZxNEDP%ArLc(w}>;3T|lc@You}fi2cWZBC5(% zaZF2A1p+A%0;dy)1Lre269Op#h;(r{2apoZvE8y-0qFg%s=D9nW}~2>A(%4!MASLc zcRN*8)nFUv%=2k2we9OZJAh47F_+$)BSz~j1Pc-|ZwV+~5rUPh$s*?KpneeVOH6HB zm7K}hAPkPwWN57Vpz`0P2D_2z!$w2=JxTr{VKj*j{~JpE(5KSVDi);{A)trAEQLTr!^nqLUv3kx+i*OiJWz%lEGbB6yGYz&E=2 Vti2b+Ar1fl002ovPDHLkV1g~ApgjNp literal 0 HcmV?d00001 diff --git a/feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt b/feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt new file mode 100644 index 00000000..1297fda8 --- /dev/null +++ b/feature/roaming/src/test/java/com/rtbishop/look4sat/feature/roaming/RoamingStateTest.kt @@ -0,0 +1,124 @@ +/* + * 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.feature.roaming + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RoamingStateTest { + + // 用户实测坐标 (QTH定位器 2.0 成品截图): 22.314066 / 108.706575 + private fun stateOf(lat: Double, lon: Double) = + roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:") + + @Test + fun `user coordinates produce OL42ih45`() { + val state = stateOf(22.314066, 108.706575) + assertEquals("OL42ih45", state.loc) + } + + @Test + fun `red marker from ih pair matches reference lookup`() { + val state = stateOf(22.314066, 108.706575) + // 成品查表: 经度第3对 'i' -> leftMargin 65dp, 纬度第3对 'h' -> topMargin 137dp + assertEquals(65, state.markerLeft) + assertEquals(137, state.markerTop) + } + + @Test + fun `grid nine from OL42 matches reference`() { + val state = stateOf(22.314066, 108.706575) + val expected = listOf( + "OL33", "OL43", "OL53", + "OL32", "OL42", "OL52", + "OL31", "OL41", "OL51" + ) + assertEquals(expected, state.grids) + } + + @Test + fun `latitude dms formatting matches reference`() { + val state = stateOf(22.314066, 108.706575) + assertEquals("纬度 22° 18' 50\" N", state.latLabel) + assertEquals("22.314066° ", state.latValue) + assertEquals("经度 108° 42' 23\" E", state.lonLabel) + assertEquals("108.706575° ", state.lonValue) + } + + @Test + fun `time is hour prefix plus fix minutes`() { + // fixTime = 2026-08-03 21:12:13 UTC+8 -> minutes 12 + val fix = 1785820333000L // 2026-08-03 21:12:13 +0800 + val state = roamingStateFromLocation(22.314066, 108.706575, fix, "21:") + assertEquals("21:12", state.time) + } + + @Test + fun `west edge borrows lon letter`() { + // 经度 0.5 纬度 22.314066: 数字对 02, 西边界 parseInt==0 + val state = stateOf(22.314066, 0.5) + assertEquals(8, state.loc.length) + // 左列经度 0->9, 经度字母 J-1=I: IL92 + assertEquals("IL92", state.grids[3]) + } + + @Test + fun `north edge carries lat letter`() { + // 纬度 29.5 经度 108.706575: 数字对 49? -> 纬度数字 9 -> 北边界 + val state = stateOf(29.5, 108.706575) + assertEquals(8, state.loc.length) + // 上排纬度 +1 -> 0, 纬度字母 +1 (M) + assertEquals("OM40", state.grids[1]) + } + + @Test + fun `east edge carries lon letter`() { + // 经度 119.5 纬度 22.314066: 经度数字 9 -> 东边界 + val state = stateOf(22.314066, 119.5) + assertEquals(8, state.loc.length) + // 右上 经度 9->0, 经度字母 O+1=P, 纬度 2+1=3: PL03 + assertEquals("PL03", state.grids[2]) + } + + @Test + fun `south edge borrows lat letter`() { + // 纬度 0.5 经度 108.706575: 纬度数字 0 -> 南边界 + val state = stateOf(0.5, 108.706575) + assertEquals(8, state.loc.length) + // 下中 纬度 0->9, 纬度字母 J-1=I: OI49 + assertEquals("OI49", state.grids[7]) + } + + @Test + fun `boundary values wrap like reference`() { + // (90, 180) -> RR99xx99 (成品区间查表行为) + val state = stateOf(90.0, 180.0) + assertEquals("RR99xx99", state.loc) + } + + @Test + fun `gps state after fix matches showLocation`() { + val state = stateOf(22.314066, 108.706575) + assertEquals(true, state.gpsOn) + assertEquals(false, state.gpsOff) + assertEquals(false, state.showSettings) + assertEquals(false, state.showProgress) + assertEquals(false, state.showNotice) + assertEquals(true, state.showDate) + } +}