feat(nav,settings): bottom nav 5+N collapsible menu + Indonesian locale

Background: 8 bottom-nav items squeeze long English labels on narrow
screens. 4.5.1 introduces the 5+N pattern: 5 main tabs plus a fixed
6th "More" button that pops a second-level menu (spring bounce) with
the remaining pages.

Changes:
- MainScreen: nav split into main (<=5, screenOrder-driven) + more
  (subMenuOrder); More button with popup panel + spring animation;
  BackHandler closes the menu before navigating back
- MoreMenuPopup: bottom-end card, current page highlighted, scrim
  click to dismiss
- UI Settings: page order card now has two zones (main menu, max 5,
  Settings locked last with no drag handle / more menu); move buttons
  between zones, drag-to-reorder within zones; new subMenuOrder pref
- Defaults: main = Satellites/Passes/Radar/Map/Settings,
  more = Mutual/Roaming/CwDecode; old screenOrder migrates by
  classifying pages against the default sub menu
- Hard-coded UI strings localized (Tracking/Lat/Lon/Qth/Connect/
  Track/Stop/CW permission prompts) into EN/ZH/TR
- NEW Indonesian locale (values-in, 181 strings + cw module strings)
  - user rule: every future release must update EN/ZH/TR/IN
- Version 4.5.1 (451)

Verified: check_strings.py OK (8 files, no bare apostrophes);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
This commit is contained in:
mckero committed 2026-08-04 08:58:05 +00:00
1 parent 760a26b007
commit 38cf7a3569
19 files changed
+753 -172

No files matched your search

@@ -181,7 +181,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Microphone permission is required for CW decoding",
text = stringResource(R.string.cw_mic_permission),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp)
@@ -190,7 +190,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
Button(onClick = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) {
Text("Grant permission")
Text(stringResource(R.string.cw_grant_permission))
}
if (permanentlyDenied) {
// 勾选了"不再询问": 引导去系统设置开启
@@ -202,7 +202,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
)
)
}) {
Text("Open app settings")
Text(stringResource(R.string.cw_open_settings))
}
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Diam</string>
<string name="waterfall_display">Tampilan waterfall</string>
<string name="frequency_scale">Skala frekuensi</string>
<string name="decoded_text">Teks terdekode</string>
<string name="clear">Bersihkan</string>
<string name="pause">Jeda Dekode</string>
<string name="save_text">Simpan Teks</string>
<string name="record_signals">Rekam Sinyal</string>
<string name="settings">Pengaturan</string>
<string name="help">Bantuan Daring</string>
<string name="rate">Nilai Aplikasi Ini</string>
<string name="premium">Dapatkan Versi Premium</string>
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
<string name="message_type">Tipe Pesan</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
<string name="cw_grant_permission">Beri izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Beklemede</string>
<string name="waterfall_display">Şelale görüntüsü</string>
<string name="frequency_scale">Frekans ölçeği</string>
<string name="decoded_text">Çözülen metin</string>
<string name="clear">Temizle</string>
<string name="pause">Çözmeyi Duraklat</string>
<string name="save_text">Metni Kaydet</string>
<string name="record_signals">Sinyalleri Kaydet</string>
<string name="settings">Ayarlar</string>
<string name="help">Çevrimiçi Yardım</string>
<string name="rate">Bu Uygulamayı Değerlendir</string>
<string name="premium">Premium Sürümü Al</string>
<string name="tap_back_again_to_close">Kapatmak için Geriye tekrar dokunun</string>
<string name="message_type">Mesaj Türü</string>
<string name="cw_mic_permission">CW çözme için mikrofon izni gereklidir</string>
<string name="cw_grant_permission">İzin ver</string>
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">空闲</string>
<string name="waterfall_display">瀑布图</string>
<string name="frequency_scale">频率刻度</string>
<string name="decoded_text">解码文本</string>
<string name="clear">清除</string>
<string name="pause">暂停解码</string>
<string name="save_text">保存文本</string>
<string name="record_signals">录音信号</string>
<string name="settings">设置</string>
<string name="help">在线帮助</string>
<string name="rate">评价此应用</string>
<string name="premium">获取高级版</string>
<string name="tap_back_again_to_close">再次按返回键退出应用</string>
<string name="message_type">消息类型</string>
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
<string name="cw_grant_permission">授予权限</string>
<string name="cw_open_settings">打开应用设置</string>
</resources>
@@ -16,4 +16,7 @@
<string name="premium">Get Premium Version</string>
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
<string name="message_type">Message Type</string>
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
<string name="cw_grant_permission">Grant permission</string>
<string name="cw_open_settings">Open app settings</string>
</resources>
@@ -461,19 +461,19 @@ private fun ExpandedRadioControl(
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
CardButton(
onClick = { onAction(RadarAction.ConnectRadios) },
text = "Connect",
text = stringResource(R.string.connect_radio),
modifier = Modifier.weight(1f)
)
} else {
CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) },
text = "Disconnect",
text = stringResource(R.string.disconnect_radio),
modifier = Modifier.weight(1f)
)
}
CardButton(
onClick = { onAction(RadarAction.ToggleTracking) },
text = if (radioControl.isTracking) "Stop" else "Track",
text = if (radioControl.isTracking) stringResource(R.string.stop_radio) else stringResource(R.string.track_radio),
modifier = Modifier.weight(1f)
)
}
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width
@@ -96,6 +97,8 @@ import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.defaultScreenOrder
import com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
import java.util.Date
@@ -327,9 +330,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens,
screenOrder = uiState.otherSettings.screenOrder,
subMenuOrder = uiState.otherSettings.subMenuOrder,
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
onResetOrder = { onAction(SettingsAction.ResetScreenOrder) }
onResetOrder = { onAction(SettingsAction.ResetMenuOrder) },
onUpdateMenu = { main, sub -> onAction(SettingsAction.UpdateMenuOrder(main, sub)) }
)
}
item { CardCredits() }
@@ -370,11 +375,11 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°")
Text(text = stringResource(R.string.loc_lat_text, settings.stationPos.latitude))
Text(text = stringResource(R.string.loc_lon_text, settings.stationPos.longitude))
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Qth: ${settings.stationPos.qthLocator}")
Text(text = stringResource(R.string.loc_qth_text, settings.stationPos.qthLocator))
}
Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -557,9 +562,11 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
private fun UiSettingsCard(
hiddenScreens: List<String>,
screenOrder: List<String>,
subMenuOrder: List<String>,
onToggle: (String) -> Unit,
onReorder: (List<String>) -> Unit,
onResetOrder: () -> Unit
onResetOrder: () -> Unit,
onUpdateMenu: (List<String>, List<String>) -> Unit
) {
val screens = listOf(
R.string.nav_sat to "Satellites",
@@ -571,6 +578,25 @@ private fun UiSettingsCard(
R.string.nav_map to "Map",
R.string.nav_prefs to "Settings"
) // name 必须与 Screen.screenId 一致 (R8 安全)
// 主菜单项: 排除更多菜单项, Settings 固定最后
val mainItems = remember(screens, screenOrder, subMenuOrder) {
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
screens.map { it.second }
.filter { it !in sub }
.sortedBy { name ->
screenOrder.indexOf(name).let { idx ->
if (idx != -1) idx else defaultScreenOrder.indexOf(name).let {
if (it != -1) it else Int.MAX_VALUE
}
}
}
.sortedWith(compareBy { if (it == "Settings") 1 else 0 })
}
// 更多菜单项
val subItems = remember(screens, subMenuOrder) {
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
sub.filter { s -> screens.any { (_, name) -> name == s } }
}
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
@@ -594,7 +620,45 @@ private fun UiSettingsCard(
text = stringResource(id = R.string.prefs_ui_order_title),
color = MaterialTheme.colorScheme.primary
)
DragOrderList(screens = screens, screenOrder = screenOrder, onReorder = onReorder)
// 主菜单区(底部栏, 最多 5 项, 设置锁定最后)
Text(
text = stringResource(id = R.string.prefs_ui_main_title),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
DragOrderList(
items = mainItems,
labels = screens,
locked = setOf("Settings"),
moveLabel = stringResource(id = R.string.prefs_ui_move_out),
moveEnabled = { name -> name != "Settings" },
onMove = { name ->
onUpdateMenu(
mainItems.filter { it != name },
(subMenuOrder.ifEmpty { defaultSubMenuOrder } + name).distinct()
)
},
onReorder = { main -> onUpdateMenu(main, subMenuOrder) }
)
// 更多菜单区(进「更多」的页面)
Text(
text = stringResource(id = R.string.prefs_ui_sub_title),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
DragOrderList(
items = subItems,
labels = screens,
moveLabel = stringResource(id = R.string.prefs_ui_move_back),
moveEnabled = { mainItems.size < 5 },
onMove = { name ->
onUpdateMenu(
(screenOrder.ifEmpty { defaultScreenOrder } + name).distinct(),
subItems.filter { it != name }
)
},
onReorder = { sub -> onUpdateMenu(screenOrder, sub) }
)
TextButton(onClick = onResetOrder) {
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
}
@@ -605,25 +669,18 @@ private fun UiSettingsCard(
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
@Composable
private fun DragOrderList(
screens: List<Pair<Int, String>>,
screenOrder: List<String>,
items: List<String>,
labels: List<Pair<Int, String>>,
locked: Set<String> = emptySet(),
moveLabel: String? = null,
moveEnabled: (String) -> Boolean = { true },
onMove: ((String) -> Unit)? = null,
onReorder: (List<String>) -> Unit
) {
val itemHeight = 48.dp
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() }
// 按 screenOrder 排序(空 = 默认顺序); remember 以 screens 参数为 key, 持久化后重组
val ordered = remember(screens, screenOrder) {
screens.sortedBy { (_, name) ->
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置, 再兜底最后
screenOrder.indexOf(name).let {
if (it != -1) it else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(name).let {
if (it != -1) it else Int.MAX_VALUE
}
}
}
}
val listState = rememberLazyListState()
val items = remember(ordered) { mutableStateListOf<Pair<Int, String>>().apply { addAll(ordered) } }
val localItems = remember(items) { mutableStateListOf<String>().apply { addAll(items) } }
var draggingIndex by remember { mutableIntStateOf(-1) }
var dragOffset by remember { mutableStateOf(0f) }
@@ -632,10 +689,11 @@ private fun DragOrderList(
state = listState,
modifier = Modifier
.fillMaxWidth()
.height(itemHeight * items.size)
.height(itemHeight * localItems.size)
) {
itemsIndexed(items, key = { _, s -> s.second }) { index, screen ->
itemsIndexed(localItems, key = { _, name -> name }) { index, name ->
val isDragging = draggingIndex == index
val isLocked = name in locked
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -646,46 +704,61 @@ private fun DragOrderList(
.animateItem()
) {
Text(
text = stringResource(id = screen.first),
text = stringResource(id = labels.first { it.second == name }.first),
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(48.dp)
.padding(20.dp)
.pointerInput(screen.second) {
detectDragGestures(
onDragStart = { draggingIndex = items.indexOf(screen) },
onDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount.y
val currentIndex = draggingIndex
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
.coerceIn(0, items.size - 1)
if (target != currentIndex) {
items.add(target, items.removeAt(currentIndex))
dragOffset += (currentIndex - target) * itemHeightPx
draggingIndex = target
}
},
onDragEnd = {
onReorder(items.map { it.second })
draggingIndex = -1
dragOffset = 0f
},
onDragCancel = {
draggingIndex = -1
dragOffset = 0f
}
)
}
) {
// 小圆点手柄(触控区 padding 20dp 居中 -> 8dp 圆点)
if (moveLabel != null && onMove != null && moveEnabled(name)) {
TextButton(onClick = { onMove(name) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
Text(moveLabel, fontSize = 11.sp)
}
}
if (isLocked) {
// 锁定行(设置页): 无抓手, 固定最后
Text(
text = stringResource(id = R.string.prefs_ui_order_locked),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp)
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
)
.size(48.dp)
.padding(20.dp)
.pointerInput(name) {
detectDragGestures(
onDragStart = { draggingIndex = localItems.indexOf(name) },
onDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount.y
val currentIndex = draggingIndex
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
.coerceIn(0, localItems.size - 1)
if (target != currentIndex) {
localItems.add(target, localItems.removeAt(currentIndex))
dragOffset += (currentIndex - target) * itemHeightPx
draggingIndex = target
}
},
onDragEnd = {
onReorder(localItems.toList())
draggingIndex = -1
dragOffset = 0f
},
onDragCancel = {
draggingIndex = -1
dragOffset = 0f
}
)
}
) {
// 小圆点手柄(触控区 padding 20dp 居中 -> 8dp 圆点)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
)
}
}
}
}
@@ -70,6 +70,10 @@ sealed interface SettingsAction {
data class ReorderScreens(val order: List<String>) : SettingsAction
// UI 设置: 重置为默认顺序
data object ResetScreenOrder : SettingsAction
// UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单)
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction
// UI 设置: 重置主/子菜单为默认
data object ResetMenuOrder : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -128,6 +128,12 @@ class SettingsViewModel(
}
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) }
is SettingsAction.UpdateMenuOrder -> settingsRepo.updateOtherSettings {
it.copy(screenOrder = action.mainOrder, subMenuOrder = action.subOrder)
}
SettingsAction.ResetMenuOrder -> settingsRepo.updateOtherSettings {
it.copy(screenOrder = emptyList(), subMenuOrder = emptyList())
}
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)