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 00000000..2202aac6 Binary files /dev/null and b/feature/roaming/src/main/res/drawable/roam_gps_green.png differ diff --git a/feature/roaming/src/main/res/drawable/roam_gps_red.png b/feature/roaming/src/main/res/drawable/roam_gps_red.png new file mode 100644 index 00000000..c0af314f Binary files /dev/null and b/feature/roaming/src/main/res/drawable/roam_gps_red.png differ 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 00000000..8a1ef8bc Binary files /dev/null and b/feature/roaming/src/main/res/drawable/roam_pnt.png differ 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) + } +}