feat(cw): wire CW decoder page into navigation (between Roaming and Map)
Compose integration of the ported CW decoder engine:
- CwDecodeScreen: AndroidView embedding the ported activity_main.xml,
lifecycle delegated to the ported MainActivity controller (onCreate ->
onResume, onDispose -> onPause/onDestroy), RECORD_AUDIO runtime
permission flow (with permanent-denial -> app settings), original
options_menu actions as a top button row (pause/clear/save/record/
settings), double-back-to-exit preserved
- CwSettingsDialog: message_type (general_text/ham_radio_qso),
text_font_size (7-99), and the 9 color keys (bg_color/text_color/...)
reading/writing the same prefs keys as the original app
(getPackageName()+"_preferences"), colors sourced from I2.b tables
- Navigation: Screen.CwDecode ("CwDecode") placed between Roaming and
Map in the default order; defaultScreenOrder updated; ic_cw morse icon;
nav_cw strings (en/zh/tr); app depends on :feature:cw
Verified: :feature:cw:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL (first pass, no errors).
This commit is contained in:
1 parent
5dc7ce6a22
commit
8bc1f16e2c
10 files changed
+461
-2
No files matched your search
@@ -80,6 +80,7 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
|
|||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
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.map.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
@@ -136,7 +137,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
// 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 ->
|
.sortedBy { screen ->
|
||||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||||
if (idx == -1) Int.MAX_VALUE else idx
|
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.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
|
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
@@ -233,6 +235,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
entry<Screen.Roaming> {
|
entry<Screen.Roaming> {
|
||||||
RoamingScreen()
|
RoamingScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.CwDecode> {
|
||||||
|
CwDecodeScreen()
|
||||||
|
}
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
|
implementation(project(":feature:cw"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
|
|||||||
+4
-1
@@ -41,12 +41,15 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
|
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
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI 设置: 默认页面顺序(与 MainScreen navItems 一致)
|
// 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
|
@Serializable
|
||||||
data object RadarDestination : NavKey
|
data object RadarDestination : NavKey
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- Morse style icon: a dot above a dash -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M 8 4 L 16 4 L 16 8 L 8 8 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M 3 16 L 21 16 L 21 20 L 3 20 Z" />
|
||||||
|
</vector>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<string name="nav_sat">Uydular</string>
|
<string name="nav_sat">Uydular</string>
|
||||||
<string name="nav_pass">Geçişler</string>
|
<string name="nav_pass">Geçişler</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_cw">CW Dekoder</string>
|
||||||
<string name="nav_map">Harita</string>
|
<string name="nav_map">Harita</string>
|
||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<string name="nav_radar">雷达</string>
|
<string name="nav_radar">雷达</string>
|
||||||
<string name="nav_mutual">匹配</string>
|
<string name="nav_mutual">匹配</string>
|
||||||
<string name="nav_roaming">漫游</string>
|
<string name="nav_roaming">漫游</string>
|
||||||
|
<string name="nav_cw">CW解码</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
<string name="nav_mutual">Match</string>
|
<string name="nav_mutual">Match</string>
|
||||||
<string name="nav_roaming">Roaming</string>
|
<string name="nav_roaming">Roaming</string>
|
||||||
|
<string name="nav_cw">CW Decoder</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ include(
|
|||||||
":core:presentation"
|
":core:presentation"
|
||||||
)
|
)
|
||||||
include(
|
include(
|
||||||
|
":feature:cw",
|
||||||
":feature:map",
|
":feature:map",
|
||||||
":feature:mutual",
|
":feature:mutual",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
|
|||||||
Reference in new issue
Block a user