diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index f36df999..4556d146 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -80,6 +80,7 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination import com.rtbishop.look4sat.core.presentation.Screen import com.rtbishop.look4sat.core.presentation.hasEnoughHeight import com.rtbishop.look4sat.core.presentation.hasEnoughWidth +import com.rtbishop.look4sat.feature.cw.CwDecodeScreen import com.rtbishop.look4sat.feature.map.MapDestination import com.rtbishop.look4sat.feature.mutual.MutualScreen import com.rtbishop.look4sat.feature.mutual.MutualViewModel @@ -136,7 +137,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() // UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留) - val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.Map, Screen.Settings) + val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.Map, Screen.Settings) .sortedBy { screen -> val idx = otherSettings.screenOrder.indexOf(screen.screenId) if (idx == -1) Int.MAX_VALUE else idx @@ -162,6 +163,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { is Screen.Passes -> screen is Screen.Passes is Screen.Radar -> screen is Screen.Radar is Screen.Mutual -> screen is Screen.Mutual + is Screen.CwDecode -> screen is Screen.CwDecode is Screen.Map -> screen is Screen.Map is Screen.Settings -> screen is Screen.Settings else -> false @@ -233,6 +235,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { entry { RoamingScreen() } + entry { + CwDecodeScreen() + } entry { SettingsDestination() } @@ -289,4 +294,4 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { } } } -} +} \ No newline at end of file diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index ba7c0488..2ff4f72d 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -31,6 +31,7 @@ internal class ApplicationPlugin : Plugin { implementation(project(":core:data")) implementation(project(":core:domain")) implementation(project(":core:presentation")) + implementation(project(":feature:cw")) implementation(project(":feature:map")) implementation(project(":feature:mutual")) implementation(project(":feature:passes")) diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index 05ba2c7d..91f7ae0f 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -41,12 +41,15 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin @Serializable data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming") + @Serializable + data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode") + @Serializable data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings") } // UI 设置: 默认页面顺序(与 MainScreen navItems 一致) -val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings") +val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "CwDecode", "Map", "Settings") @Serializable data object RadarDestination : NavKey @@ -76,4 +79,4 @@ class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes) matchers.forEach { it.match(deeplink)?.let { match -> return match } } return fallbackDestination } -} +} \ No newline at end of file diff --git a/core/presentation/src/main/res/drawable/ic_cw.xml b/core/presentation/src/main/res/drawable/ic_cw.xml new file mode 100644 index 00000000..133be500 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_cw.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 3c7c4e0c..bef726e7 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -10,6 +10,7 @@ Uydular Geçişler Radar + CW Dekoder Harita Ayarlar diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 32e5d395..d859192f 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -11,6 +11,7 @@ 雷达 匹配 漫游 + CW解码 地图 设置 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 27ca4eba..9fe36145 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ Radar Match Roaming + CW Decoder Map Settings diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt new file mode 100644 index 00000000..c2387315 --- /dev/null +++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt @@ -0,0 +1,213 @@ +/* + * CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。 + * + * 照搬自 Morse Expert 1.15 的 com.ve3nea.morse_expert.MainActivity(已改为普通控制器类): + * - AndroidView 嵌入原版 activity_main.xml(ConstraintLayout 根, 含 decodedTextView/scaleView/ + * statusTextView/verticalLayout/waterfallView); + * - 生命周期 onCreate/onResume/onPause/onDestroy 全部委托给控制器, 顺序与原 Activity 一致 + * (onCreate 必须在 onResume 前, onDispose 先 onPause 再 onDestroy); + * - 麦克风权限(RECORD_AUDIO)由 Compose 侧管理, 授予后调用 onPermissionGranted()(= v()), + * 用 initialized 标记保证只调一次(v() 会 new a() 覆盖 f11036E); + * - 原 options_menu 的 清除/暂停/保存/录音/设置 改为顶部一行按钮(图标用模块内原版 drawable)。 + * + * 注意: 控制器 onCreate 内的 setImmersive(window, false) 会把宿主窗口恢复为 + * decorFitsSystemWindows(true)(原版行为照搬, 会对整个宿主 Activity 生效)。 + */ +package com.rtbishop.look4sat.feature.cw + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import android.view.LayoutInflater +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.constraintlayout.widget.ConstraintLayout +import com.ve3nea.morse_expert.MainActivity + +@Composable +fun CwDecodeScreen(navigateUp: () -> Unit = {}) { + val context = LocalContext.current + val activity = remember(context) { + context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity") + } + // 照搬的控制器(普通类, 非 Activity); 每次进入页面新建实例 + val controller = remember { MainActivity() } + // 提前 inflate 原版布局, 供 AndroidView 与控制器 onCreate 共用同一根视图 + val rootView = remember(context) { + LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout + } + + var permissionGranted by remember { + mutableStateOf( + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + var permanentlyDenied by remember { mutableStateOf(false) } + // v() 会 new a() 覆盖 f11036E, 必须保证只初始化一次 + var initialized by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } + + // 生命周期: onCreate 必须在 onResume 前; onDispose 先 onPause 再 onDestroy + DisposableEffect(Unit) { + controller.onCreate(activity, rootView) + controller.onResume() + onDispose { + controller.onPause() + controller.onDestroy() + } + } + + // 权限授予后启动解码核心; "首次进入已授权" 与 "弹窗回调授权" 两条路径统一走这里 + LaunchedEffect(permissionGranted) { + if (permissionGranted && !initialized) { + controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心 + // v() 只创建核心; 页面此刻已处于 resumed, 需再走一次 onResume 立即启动录音 + // (AudioRecord 是新建的, startRecording 不会重复; GLSurfaceView.onResume 幂等) + controller.onResume() + initialized = true + } + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + permissionGranted = granted + permanentlyDenied = !granted && + !activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) + } + + // 原版 tap_back_again_to_close: 单击返回只提示, 2 秒内再按才退出 + BackHandler { + if (!controller.handleBackPress()) navigateUp() + } + + if (showSettings) { + CwSettingsDialog(controller = controller, onDismiss = { showSettings = false }) + } + + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + // 原 options_menu: 暂停/清除/保存(原版图标) + 录音/设置(文字按钮) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + IconButton(onClick = { controller.togglePause() }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_pause_24), + contentDescription = stringResource(R.string.pause) + ) + } + IconButton(onClick = { controller.clearDecoded() }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_delete_24), + contentDescription = stringResource(R.string.clear) + ) + } + IconButton(onClick = { controller.saveText() }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_save_24), + contentDescription = stringResource(R.string.save_text) + ) + } + // recordSignals 需要解码核心(f11037F), 未初始化时禁用 + TextButton( + onClick = { if (initialized) controller.recordSignals() }, + enabled = initialized + ) { + Text(stringResource(R.string.record_signals)) + } + TextButton(onClick = { showSettings = true }) { + Text(stringResource(R.string.settings)) + } + } + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + AndroidView( + factory = { rootView }, + modifier = Modifier.fillMaxSize() + ) + if (!permissionGranted) { + // 未授予麦克风权限: 遮罩 + 提示文字 + 请求按钮 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x99000000)), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Microphone permission is required for CW decoding", + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Button(onClick = { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + }) { + Text("Grant permission") + } + if (permanentlyDenied) { + // 勾选了"不再询问": 引导去系统设置开启 + TextButton(onClick = { + activity.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${activity.packageName}") + ) + ) + }) { + Text("Open app settings") + } + } + } + } + } + } + } +} diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt new file mode 100644 index 00000000..ce817298 --- /dev/null +++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt @@ -0,0 +1,218 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.rtbishop.look4sat.feature.cw + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.ve3nea.morse_expert.MainActivity +import kotlin.math.roundToInt + +/** 键名/默认值照搬原 Morse Expert(root_preferences.xml + SettingsActivity 逻辑)。 */ +private const val KEY_MESSAGE_TYPE = "message_type" +private const val KEY_TEXT_FONT_SIZE = "text_font_size" +private const val DEFAULT_MESSAGE_TYPE = "general_text" +private const val DEFAULT_TEXT_FONT_SIZE = 18 +private const val MIN_FONT_SIZE = 7 +private const val MAX_FONT_SIZE = 99 + +/** 预置色板(9 色, 3x3 网格);原 ColorPreferenceCompat 用第三方 colorpicker, 不引入。 */ +private val PaletteColors = listOf( + 0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(), + 0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(), + 0xFF0000FF.toInt(), 0xFFFFFF00.toInt(), 0xFFFF00FF.toInt(), +) + +/** + * CW 设置弹窗(替代原 Morse Expert 的 SettingsActivity)。 + * 设置项与键名照搬原 APK: message_type / text_font_size / 9 个颜色 + * (I2.b.f663b 键名, I2.b.f664d 默认值, I2.b.c 英文标题)。 + * 存储: SharedPreferences(getPackageName() + "_preferences")。 + * 点击 OK 保存并调用 controller.onResume() 立即生效; Cancel/点外部仅关闭不保存。 + */ +@Composable +fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) { + val activity = controller.mActivity ?: return + val prefs = remember(activity) { + activity.getSharedPreferences(activity.packageName + "_preferences", Context.MODE_PRIVATE) + } + + var messageType by remember { + mutableStateOf(prefs.getString(KEY_MESSAGE_TYPE, DEFAULT_MESSAGE_TYPE) ?: DEFAULT_MESSAGE_TYPE) + } + var fontSize by remember { + mutableStateOf( + prefs.getInt(KEY_TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE) + .toFloat().coerceIn(MIN_FONT_SIZE.toFloat(), MAX_FONT_SIZE.toFloat()) + ) + } + var colorValues by remember { + mutableStateOf(IntArray(9) { i -> prefs.getInt(I2.b.f663b[i], I2.b.f664d[i]) }) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("CW Settings") }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(top = 4.dp) + ) { + Text("Message type", style = MaterialTheme.typography.titleSmall) + MessageTypeRow("general_text", "General Text", messageType) { messageType = it } + MessageTypeRow("ham_radio_qso", "Ham Radio QSO", messageType) { messageType = it } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + Text("Text font size", style = MaterialTheme.typography.titleSmall) + Row(verticalAlignment = Alignment.CenterVertically) { + Slider( + value = fontSize, + onValueChange = { fontSize = it }, + valueRange = MIN_FONT_SIZE.toFloat()..MAX_FONT_SIZE.toFloat(), + steps = MAX_FONT_SIZE - MIN_FONT_SIZE - 1, + modifier = Modifier.weight(1f), + ) + Text( + text = "${fontSize.roundToInt()}", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.End, + modifier = Modifier.width(44.dp), + ) + } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + Text("Colors", style = MaterialTheme.typography.titleSmall) + for (i in 0 until 9) { + ColorSettingRow( + title = I2.b.c[i], + value = colorValues[i], + onSelect = { selected -> + colorValues = colorValues.copyOf().also { it[i] = selected } + }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + val editor = prefs.edit() + editor.putString(KEY_MESSAGE_TYPE, messageType) + editor.putInt(KEY_TEXT_FONT_SIZE, fontSize.roundToInt()) + for (i in 0 until 9) { + editor.putInt(I2.b.f663b[i], colorValues[i]) + } + editor.apply() + // 原 SettingsActivity 返回时由 MainActivity.onResume() 重新读取全部设置; 照搬逻辑已就位 + controller.onResume() + onDismiss() + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun MessageTypeRow(option: String, label: String, selected: String, onSelect: (String) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .clickable { onSelect(option) } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = option == selected, onClick = { onSelect(option) }) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun ColorSettingRow(title: String, value: Int, onSelect: (Int) -> Unit) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Box( + Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color(value)), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = String.format("#%06X", value and 0xFFFFFF), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(6.dp)) + PaletteGrid(selected = value, onSelect = onSelect) + } +} + +@Composable +private fun PaletteGrid(selected: Int, onSelect: (Int) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + PaletteColors.chunked(3).forEach { rowColors -> + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + rowColors.forEach { color -> + val isSelected = color == selected + Box( + modifier = Modifier + .size(36.dp) + .border( + width = if (isSelected) 3.dp else 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(6.dp), + ) + .clip(RoundedCornerShape(6.dp)) + .background(Color(color)) + .clickable { onSelect(color) }, + ) + } + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d35762f1..0e2533e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include( ":core:presentation" ) include( + ":feature:cw", ":feature:map", ":feature:mutual", ":feature:passes",