Compare commits

...
16 Commits
Author SHA1 Message Date
mckero dd378990fe feat(wavelog): group log entries by pass session (satellite + AOS timestamp)
User request: logs should be separated by satellite/pass. Each pass
session gets an ID = satellite name + AOS timestamp (the second the
elevation hits 0, from OrbitalPass.aosTime), e.g.
"ASRTU-1-20260804-2014". Log page groups entries by session:
group title (satellite - local time) + thick divider line between
groups (md --- style); legacy entries without sessionId fall into
"Ungrouped" at the end.

- WavelogQso: +sessionId (persisted in queue JSON)
- LogTab: sessionId built from satelliteName + aosTimeMs (radar page
  passes currentPass.aosTime)
- WavelogLogScreen: grouped rendering + wavelog_ungrouped string (4 locales)
- sessionId UTC yyyyMMdd-HHmm; display converts to local time

Verified: check_strings OK (9 files); :app:compileDebugKotlin
BUILD SUCCESSFUL. Not released (batched).
2026-08-04 14:39:01 +00:00
mckero a23bf34ccf fix(radar): swipe-delete visuals restored (yellow trash + undo countdown)
Previous fix added the content-layer background OUTSIDE the offset
modifier, so the background stayed at the original position and
permanently covered the swipe-reveal area - the yellow trash icon,
75% undo button and 5s countdown were invisible while swiping.
Moved the background INSIDE the offset (background follows the
content): idle = fully covered (no bleed into Sent column), swiping
= yellow trash / undo countdown revealed as before.

Verified: :feature:radar:compileDebugKotlin BUILD SUCCESSFUL.
Not released (batched with pending fixes).
2026-08-04 14:22:27 +00:00
mckero a724c0823d fix(i18n): add values-id resource dir so Indonesian devices pick the locale
Friend's device (system language Bahasa Indonesia) fell back to
English even though values-in exists. Modern Android devices report
the Indonesian locale as "id" (ISO-639-1 current code; "in" is the
legacy alias) and resource matching is strict. Added values-id as a
copy of values-in in core/presentation and feature/cw (both language
directories ship the same translations).

Verified: check_strings OK (9 files incl. values-id);
:app:compileDebugKotlin BUILD SUCCESSFUL. Not released (batch with
pending fixes).
2026-08-04 14:18:00 +00:00
mckero cda6cf8583 fix(gps,data): official-code fixes - real GPS result callback, no fake update success (4.5.2, release pending)
Official code issues found during review (user-reported):
1. GPS "success" was shown instantly even when no fix was obtained:
   - setStationPosition() always returned true (permissions exception
     swallowed, async requestLocationUpdates without waiting)
   - now: suspend + LocationManagerCompat.getCurrentLocation (GPS
     first, network fallback), permission check upfront, 15s timeout,
     success only when onLocationChanged fires; SettingsRepo takes
     Context for the permission check; ViewModel waits for the real
     result and shows "Unable to get location - check permission and
     GPS/network signal" on failure (4 locales)
2. Data update faked success on total failure:
   - updateFromRemote now counts successful sources; 0 success throws
     IOException -> timestamp NOT refreshed, Toast "Update failed -
     check your network" (4 locales, new IShowToast resId overload)
   - OkHttp timeouts widened: connect 15s / read 20s / write 20s

Verified: check_strings OK; all modules compile.
Release intentionally NOT triggered (user: fix everything first, then
one release).
2026-08-04 14:08:38 +00:00
mckero c09e87fa4e fix(radar,settings): realtime doppler freq in Log tab, trash overlay fix, sub-menu swap (4.5.2 overwrite 5)
User-test fixes:
1. Log tab frequency now refreshes every second with the transponder
   panel (multi-Doppler): selectedRadio derived by uuid from the
   per-second transceivers list instead of a remembered stale
   reference; display and upload use radio.uplinkLow/downlinkLow
   (the same doppler-corrected values the transceiver panel shows).
2. SwipeDeleteRow: content layer now has an opaque background so the
   trash icon only appears while swiping (was bleeding through into
   the "Sent" column).
3. Settings page order: More-menu items can now move back into the
   main menu - button always visible; when the main menu is full (5),
   the last non-Settings item is automatically swapped into More.

Verified: check_strings OK (8 files); :app:compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-04 13:39:22 +00:00
mckero 0bcf73e1f9 fix(wavelog,radar): sat name LoTW normalization, full grid lines, RX freq restored (4.5.2 overwrite 4) 2026-08-04 12:29:31 +00:00
mckero ab1a0c0891 fix(nav,radar,wavelog): 4.5.2 third overwrite - log page entry visible, freq format 3 decimals, direct freq extraction
User-test fixes (3rd overwrite, version stays 4.5.2/452):

1. Log page was invisible everywhere outside the radar tab:
   - allNavItems in MainScreen.kt was missing Screen.WavelogLog ->
     not in bottom nav, not in More menu
   - SettingsScreen UI-order lists were missing the WavelogLog row ->
     not in page order settings either
   Now the Log page appears in the More menu (default sub menu tail,
   migration appends it) AND in UI settings page-order lists.
2. Frequency display: formatFrequency was MHz.kHz.Hz (145.900.000);
   now MHz.kHz (145.900) per request. Applies to transceiver panel
   and Log page alike (shared formatter).
3. Log tab frequency: now extracts the exact numbers shown in the
   transceiver panel (txBaseFrequencyHz for TX, uplinkLow/uplinkHigh
   range for linear) - no re-computation, no extra decimals. Upload
   uses the same tuned frequency (Hz precision kept internally).
4. Log page (More menu) table: date+time column (MM-dd HH:mm),
   row separators (table lines), uploaded checkmark kept.

Verified: check_strings OK (8 files); :app:compileDebugKotlin
BUILD SUCCESSFUL (includes upstream merge 1a552417).
2026-08-04 12:07:45 +00:00
mckero 1a55241791 merge: upstream atsunatsu CW decoder + linear calculator (keep ours, absorb Calculator tab)
Upstream added a333192d (CW decoder and linear calculator page) and
c80e3512 (linear calculator offset mapping fix). Merged with all 7
conflicts resolved by hand:

- app/build.gradle.kts: keep ours (keystore signing config + abiFilters)
- CwDecodeScreen.kt: keep ours (only difference is localized strings
  vs upstream hardcoded English)
- g3/d.java: keep ours (identical lookup table, 4-line diff)
- app_values.xml: keep ours (3 CW permission strings upstream lacks)
- feature/radar/build.gradle.kts: keep ours (same dependency line)
- TransceiversPage.kt: take upstream (includes CalculatorPage +
  our CW panel + INV title logic; conflicts were comment-only)
- RadarScreen.kt: both sides - keep our wavelog imports + Log tab,
  take upstream DopplerFrequencyCalculator import + Calculator tab;
  pages = Transceivers, Log, [Calculator if any named linear
  transponder per upstream isNamedLinearTransponder], Sstv

Result: radar page tabs are Transceivers / Log / Calculator (linear
sats only, upstream logic) / SSTV. WaveLog Log tab and all our
localization/signing preserved.

NOTE: committed WITHOUT building per user request - merge compile
verification deferred.
2026-08-04 11:59:15 +00:00
mckero 9d437a90c9 fix(wavelog,radar): 4.5.2 second overwrite - v1 API support, error dialog with copy, frequency sync
Background: user's WaveLog server has NO v2 API (all /api/v2/* return
404; /api/qso v1 works - verified with curl). First fix only tried v2
paths, so uploads still failed with 404. Also: Log tab frequencies did
not match the Doppler panel, linear transponders showed a single
frequency instead of the passband range, and errors were toast-only
(no copy).

Changes:
- WaveLogApi: full v1 support with auto fallback
  - v2 first (Bearer header + JSON fields), on 404 fall back to v1
    (key inside JSON body + ADIF string) - both with and without
    index.php prefix
  - test connection: v2 GET api/v2/token -> v1 POST
    api/get_contacts_adif (validates key + station id)
  - station gridsquare is v2-only; on v1 the uploader falls back to
    the user's QTH grid (grid check skipped/equal)
  - v1 ADIF: call/band=SAT/mode/freq+freq_rx (MHz)/qso_date/time_on
    (UTC, compact)/gridsquare(4)/sat_name/prop_mode=SAT, byte-length
    field prefixes
  - 409 duplicate counts as success in both versions
- Error dialog with copy: test/upload failures now open an AlertDialog
  with the full error (incl. actual URL + HTTP code), Copy button
  (ClipboardManager) and Cancel; uploader collects the first failure
  message
- Log tab frequency sync: LogTab receives txBaseFrequencyHz from the
  radar page (the tuned frequency shown in the transceiver panel);
  RX is computed through the same Doppler mapping as the Doppler
  panel; linear transponders show the full uplink/downlink range
  (Doppler-corrected low-high) instead of a single frequency
- Restored uploadWavelogQueue (lost in an earlier patch)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 11:47:06 +00:00
mckero 5351afe508 fix(wavelog,radar): 4.5.2 user-test fixes (404 upload, swipe delete, transponder picker, log page)
Background: user tested 4.5.2 and reported 4 issues. Same-version
overwrite per user (4.5.2 exists solely for the logbook system).

Fixes:
1. Upload 404 — root cause: user server URL ending in /index.php was
   concatenated again (/index.php/index.php/api/v2/...) -> 404. Now:
   - normalizeUrl strips trailing /index.php
   - every request tries the index.php path first, falls back to the
     rewritten path on 404
   - 409 conflict (duplicate QSO) counts as success (moves out of queue)
   - failure messages include the actual URL + HTTP code for debugging
2. Swipe-to-delete was dead: rowWidth was never measured (0) so the
   75% threshold was 0 and the drag was clamped to 0. Now measured via
   onSizeChanged + smooth spring/tween snap-back animation.
3. Transponder picker: long card list replaced with an
   ExposedDropdownMenuBox dropdown (scrollable menu, pick one).
4. New Log page under the More menu: table view (time / frequency /
   satellite / callsign / uploaded checkmark). WavelogQso gains
   uploaded flag; uploader marks instead of removing; queue keeps
   uploaded entries (500 cap). Old persisted subMenuOrder gets
   WavelogLog appended (migration).

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 11:06:47 +00:00
atsunatsu c80e351212 fix: correct linear calculator offset mapping 2026-08-04 18:41:53 +08:00
mckero 916a0d0f0a feat(wavelog,radar,settings): WaveLog logbook integration + Log tab
Background: satellite operators want to log QSOs during a pass while
watching live frequencies. 4.5.2 adds WaveLog (logbook server) API v2
integration: log from the radar page, upload to a self-hosted WaveLog
instance with grid-mismatch protection.

Changes:
- Radar page: new third tab "Log" between Transceivers and SSTV
  - pick a transponder, watch live TX/RX Doppler-corrected frequencies
  - enter callsign, Enter stores locally (UTC time + that second's
    frequencies sampled together)
  - local entry list shows time/frequency/callsign only (no upload
    status, per user: proves the entry was saved)
  - swipe-to-delete: yellow trash while swiping, turns into Undo at
    75%, 5s countdown before auto-delete (custom gesture, no
    SwipeToDismissBox)
- Settings: new WaveLog card (server URL / API key / station ID /
  auto-upload switch / test connection / upload now buttons) matching
  the user's reference screenshot layout
- Upload pipeline: POST /index.php/api/v2/qso with required fields
  (station_profile_id, call, band=SAT, mode, qso_date, time_on UTC)
  plus freq/freq_rx (Hz), gridsquare (from station profile via
  GET /api/v2/station/{id}), sat_name; RST omitted per user
- Grid check: station gridsquare (first 4) vs user QTH (first 4);
  mismatch shows a confirm dialog (ignore & upload / cancel)
- Auto upload: 10-minute in-app retry loop (only when switch on);
  manual upload button; local queue capped at 500, all entries stored
  locally regardless of switch
- Fixed: subMenuOrder was never persisted (4.5.1 regression)
- core/domain: compileOnly org.json (runtime uses Android's)
- Version 4.5.2 (452)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
2026-08-04 10:29:40 +00:00
atsunatsu a333192df5 feat: add CW decoder and linear calculator page 2026-08-04 17:49:28 +08:00
mckero 38cf7a3569 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.
2026-08-04 08:58:05 +00:00
mckero 760a26b007 Merge remote-tracking branch 'atsunatsu/main' 2026-08-04 08:10:17 +00:00
atsunatsu a7b41c95c7 fix: improve match header and daily sun times
Match page UI:
- Always show a meaningful status line in the top bar instead of leaving the second row blank on first entry.
- Add a compact status chip for waiting, calculating, result, no-match, and error states.
- Remove the duplicate intro card so the first screen starts directly with station inputs.

Pass list sun times:
- Compute sunrise/sunset from each visible date group's 00:00 in the selected timezone.
- Avoid using an arbitrary pass AOS as the rise/set search start, which could jump later-day headers to the following day's events.
2026-08-03 21:38:57 +08:00
42 changed files with 2866 additions and 160 deletions

No files matched your search

@@ -17,16 +17,23 @@
*/ */
package com.rtbishop.look4sat package com.rtbishop.look4sat
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@@ -53,6 +60,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -86,6 +96,7 @@ import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
import com.rtbishop.look4sat.feature.roaming.RoamingScreen import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination
@@ -137,7 +148,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.CwDecode, Screen.Map, Screen.Settings) val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.Map, Screen.Settings)
.sortedBy { screen -> .sortedBy { screen ->
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后 // 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
val idx = otherSettings.screenOrder.indexOf(screen.screenId) val idx = otherSettings.screenOrder.indexOf(screen.screenId)
@@ -147,6 +158,19 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
} }
} }
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings } .filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
val mainNavItems = remember(allNavItems, subOrder) {
allNavItems.filter { it.screenId !in subOrder }.take(5)
}
val moreNavItems = remember(allNavItems, subOrder) {
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
}
var moreExpanded by remember { mutableStateOf(false) }
// 更多菜单打开时拦截返回: 先关菜单
BackHandler(enabled = moreExpanded) { moreExpanded = false }
// Activity-scoped so the mutual query results survive navigation to Radar and back // Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel( val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner, viewModelStoreOwner = context as ViewModelStoreOwner,
@@ -161,13 +185,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
) { ) {
NavigationSuiteScaffold( NavigationSuiteScaffold(
navigationSuiteItems = { navigationSuiteItems = {
navItems.forEach { screen -> mainNavItems.forEach { screen ->
val isSelected = when (currentKey) { val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites is Screen.Satellites -> screen is Screen.Satellites
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.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
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
@@ -178,11 +203,26 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
selected = isSelected, selected = isSelected,
onClick = { onClick = {
if (isSelected) return@item if (isSelected) return@item
moreExpanded = false
while (backStack.size > 1) backStack.removeAt(backStack.size - 1) while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen) if (screen !is Screen.Passes) backStack.add(screen)
} }
) )
} }
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
if (moreNavItems.isNotEmpty()) {
item(
icon = {
Icon(
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
)
},
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
selected = moreExpanded,
onClick = { moreExpanded = !moreExpanded }
)
}
}, },
navigationSuiteColors = NavigationSuiteDefaults.colors( navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
@@ -193,10 +233,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
else -> NavigationSuiteType.ShortNavigationBarMedium else -> NavigationSuiteType.ShortNavigationBarMedium
} }
) { ) {
Column { Box {
Column(modifier = Modifier.fillMaxSize()) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f).fillMaxWidth(),
onBack = navigateBack, onBack = navigateBack,
transitionSpec = { fadeTransition }, transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition }, popTransitionSpec = { fadeTransition },
@@ -216,7 +257,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
container.setMutualPassData(MutualPassData()) container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
// navigateToRadar() // navigateToRadar()
} }
} }
entry<Screen.Radar> { entry<Screen.Radar> {
@@ -242,6 +283,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.CwDecode> { entry<Screen.CwDecode> {
CwDecodeScreen() CwDecodeScreen()
} }
entry<Screen.WavelogLog> {
WavelogLogScreen(queue = container.wavelogQueue)
}
entry<Screen.Settings> { entry<Screen.Settings> {
SettingsDestination() SettingsDestination()
} }
@@ -280,7 +324,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}", text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
@@ -296,6 +340,27 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
} }
} }
} }
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
AnimatedVisibility(
visible = moreExpanded,
modifier = Modifier.fillMaxSize(),
enter = expandVertically(
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
) + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
MoreMenuPopup(
items = moreNavItems,
currentKey = currentKey,
onDismiss = { moreExpanded = false },
onSelect = { screen ->
moreExpanded = false
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
}
}
} }
} }
} }
@@ -0,0 +1,106 @@
/*
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
*
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
* (MainScreen 的 AnimatedVisibility + spring)驱动。
*/
package com.rtbishop.look4sat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation3.runtime.NavKey
import com.rtbishop.look4sat.core.presentation.Screen
@Composable
fun MoreMenuPopup(
items: List<Screen>,
currentKey: NavKey?,
onDismiss: () -> Unit,
onSelect: (Screen) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
.clickable(onClick = onDismiss)
) {
Card(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
items.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
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.WavelogLog -> screen is Screen.WavelogLog
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(screen) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId),
tint = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(screen.titleResId),
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Text(
text = "›",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>
@@ -40,6 +40,9 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.wavelog.IWavelogQueueStore
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -96,6 +99,16 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideSaveImage(): ISaveImage = SaveImage(context) override fun provideSaveImage(): ISaveImage = SaveImage(context)
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
override val wavelogQueue: WavelogQueue by lazy {
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
WavelogQueue(object : IWavelogQueueStore {
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
})
}
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
override fun provideBluetoothReporter(): IReporter { override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value val rc = settingsRepo.rcSettings.value
@@ -160,7 +173,14 @@ class MainContainer(private val context: Context) : IMainContainer {
} }
private fun provideRemoteSource(): IRemoteSource { private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build()) return RemoteSource(
Dispatchers.IO, context.contentResolver,
OkHttpClient.Builder()
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.build()
)
} }
private fun provideSatelliteRepo(): ISatelliteRepo { private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -176,6 +196,6 @@ class MainContainer(private val context: Context) : IMainContainer {
val appPrefsFileName = "${context.packageName}_preferences" val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE) val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4" val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
return SettingsRepo(manager, appPreferences, appVersionName) return SettingsRepo(context, manager, appPreferences, appVersionName)
} }
} }
@@ -81,14 +81,21 @@ class DatabaseRepo(
// launch all network requests concurrently // launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
if (successCount == 0) {
throw java.io.IOException("All data sources failed to download")
}
// parse fetched data concurrently and associate with types // parse fetched data concurrently and associate with types
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> val importedEntries = tleResults.flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries -> stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum }) settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
} }
} }
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) -> val importedRadios = radioResults.flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
} }
// insert parsed data into the database // insert parsed data into the database
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
class SettingsRepo( class SettingsRepo(
private val context: android.content.Context,
private val locationManager: LocationManager, private val locationManager: LocationManager,
private val preferences: SharedPreferences, private val preferences: SharedPreferences,
override val appVersionName: String override val appVersionName: String
@@ -94,6 +95,11 @@ class SettingsRepo(
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl" private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl" private val keyTransceiversUrl = "transceiversUrl"
private val keySubMenuOrder = "subMenuOrder"
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogApiKey = "wavelogApiKey"
private val keyWavelogStationId = "wavelogStationId"
private val keyWavelogAutoUpload = "wavelogAutoUpload"
private val separatorComma = "," private val separatorComma = ","
//region # Satellites selection settings //region # Satellites selection settings
@@ -181,23 +187,41 @@ class SettingsRepo(
return true return true
} }
override fun setStationPosition(): Boolean { /** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
override suspend fun setStationPosition(): Boolean {
// 权限前置: 无定位权限直接失败(不吞异常)
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.ACCESS_FINE_LOCATION
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
println("GPS: no fine location permission")
return false
}
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
try { return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps) val signal = android.os.CancellationSignal()
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet) val handler = android.os.Handler(android.os.Looper.getMainLooper())
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef val executor = java.util.concurrent.Executor { handler.post(it) }
val location = locationManager.getLastKnownLocation(providerDef) // 15 秒超时
if (location == null || System.currentTimeMillis() - location.time > 600_000L) { val timeout = handler.postDelayed({
println("Requesting location for $provider provider") signal.cancel()
locationManager.requestLocationUpdates(provider, 0L, 0f, this) if (cont.isActive) cont.resume(false) { }
} else { }, 15_000L)
val listener = androidx.core.util.Consumer<Location> { location ->
handler.removeCallbacksAndMessages(null)
setStationPosition(location.latitude, location.longitude, location.altitude) setStationPosition(location.latitude, location.longitude, location.altitude)
if (cont.isActive) cont.resume(true) { }
} }
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
val provider = if (hasGps) providerGps else providerNet
try {
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
} catch (exception: SecurityException) { } catch (exception: SecurityException) {
println("No permissions were given - $exception") handler.removeCallbacksAndMessages(null)
if (cont.isActive) cont.resume(false) { }
}
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
} }
return true
} }
override fun setStationPosition(locator: String): Boolean { override fun setStationPosition(locator: String): Boolean {
@@ -361,6 +385,11 @@ class SettingsRepo(
putLong(keyHighElevation, new.highElevation.toRawBits()) putLong(keyHighElevation, new.highElevation.toRawBits())
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet()) putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
putString(keyScreenOrder, new.screenOrder.joinToString(",")) putString(keyScreenOrder, new.screenOrder.joinToString(","))
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
putString(keyWavelogUrl, new.wavelogUrl)
putString(keyWavelogApiKey, new.wavelogApiKey)
putString(keyWavelogStationId, new.wavelogStationId)
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
} }
new new
} }
@@ -378,8 +407,13 @@ class SettingsRepo(
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(), hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(), screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())), lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())) highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
) )
//endregion //endregion
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
override fun invoke(message: String) { override fun invoke(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show() Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} }
override fun invoke(resId: Int) {
invoke(context.getString(resId))
}
} }
+5
View File
@@ -1,3 +1,8 @@
plugins { plugins {
alias(libs.plugins.convention.coreDomainPlugin) alias(libs.plugins.convention.coreDomainPlugin)
} }
dependencies {
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
compileOnly("org.json:json:20240303")
}
@@ -65,8 +65,15 @@ data class OtherSettings(
val highElevation: Double = 45.0, val highElevation: Double = 45.0,
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示) // UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
val hiddenScreens: List<String> = emptyList(), val hiddenScreens: List<String> = emptyList(),
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/匹配/漫游/地图/设置) // UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
val screenOrder: List<String> = emptyList() val screenOrder: List<String> = emptyList(),
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
val subMenuOrder: List<String> = emptyList(),
// WaveLog 日志系统(4.5.2): 服务器配置
val wavelogUrl: String = "",
val wavelogApiKey: String = "",
val wavelogStationId: String = "",
val wavelogAutoUpload: Boolean = false
) )
data class DataSourcesSettings( data class DataSourcesSettings(
@@ -42,6 +42,9 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage fun provideSaveImage(): ISaveImage
// WaveLog 日志(4.5.2)
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
} }
data class MutualPassData( data class MutualPassData(
@@ -45,7 +45,8 @@ interface ISettingsRepo {
//region # Station position settings //region # Station position settings
val stationPosition: StateFlow<GeoPos> val stationPosition: StateFlow<GeoPos>
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
fun setStationPosition(): Boolean /** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
suspend fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean fun setStationPosition(locator: String): Boolean
//endregion //endregion
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
interface IShowToast { interface IShowToast {
operator fun invoke(message: String) operator fun invoke(message: String)
/** 按资源 ID 显示(四语文案) */
operator fun invoke(resId: Int)
} }
@@ -11,6 +11,7 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/** /**
* Computes Doppler-corrected reciprocal frequencies for linear transponders. * Computes Doppler-corrected reciprocal frequencies for linear transponders.
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
* Given a downlink frequency, compute the Doppler-corrected uplink frequency * Given a downlink frequency, compute the Doppler-corrected uplink frequency
* with an offset applied to the downlink (in Hz). * with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type. * Returns null if the transponder is not a linear passband type.
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink.
*/ */
fun computeUplinkFromDownlinkWithOffset( fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long, downlinkHz: Long,
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink) return orbitalPos.getUplinkFreq(baseUplink)
} }
@@ -95,4 +99,26 @@ object DopplerFrequencyCalculator {
return upLow != null && upHigh != null && downLow != null && downHigh != null return upLow != null && upHigh != null && downLow != null && downHigh != null
&& upLow != upHigh && downLow != downHigh && upLow != upHigh && downLow != downHigh
} }
/**
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for the named linear
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
}
} }
@@ -0,0 +1,266 @@
/*
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
*
* 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404):
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段)
* v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串)
* 策略: 优先 v2, 404 自动降级 v1。
* 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。
* 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 QTH。
*/
package com.rtbishop.look4sat.core.domain.wavelog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
/** 站点信息(GET /api/v2/station/{id} 结果) */
data class WavelogStation(
val id: Int,
val name: String,
val callsign: String,
val gridsquare: String
)
sealed class WavelogResult {
data class Success(val message: String) : WavelogResult()
data class Failure(val message: String) : WavelogResult()
}
object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
return u
}
/** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
// v2: GET /index.php/api/v2/token
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
// v1: POST /index.php/api/get_contacts_adif(key 在 body)
if (stationId.isNotBlank()) {
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
}
// 无 index.php 的 v1 尝试
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
}
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
if (code in 200..299) {
return@withContext try {
val obj = JSONObject(resp)
val data = obj.optJSONObject("data") ?: obj
val station = WavelogStation(
id = data.optInt("id"),
name = data.optString("name"),
callsign = data.optString("callsign"),
gridsquare = data.optString("gridsquare")
)
WavelogResult.Success(JSONObject().apply {
put("id", station.id); put("name", station.name)
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
}.toString())
} catch (e: Exception) {
WavelogResult.Failure("解析失败: ${e.message}")
}
}
// v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
WavelogResult.Success("")
}
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
.ifBlank { raw.trim() }
.uppercase(Locale.ENGLISH)
// ISS 特判: 主名是 ISS/ZARYA/ARISS 变体 → ISS(LoTW 认可名)
return when {
main == "ZARYA" || main.startsWith("ISS") -> "ISS"
main == "ARISS" -> "ISS"
else -> main
}
}
/** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */
suspend fun postQso(
url: String,
apiKey: String,
stationProfileId: String,
qso: WavelogQso,
gridsquare: String
): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso(JSON 字段)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", "SAT")
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", qso.freqTxHz)
put("freq_rx", qso.freqRxHz)
put("gridsquare", gridsquare)
put("sat_name", satName)
}
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
// v1: POST /index.php/api/qso(key 在 body + ADIF)
val v1Body = JSONObject().apply {
put("key", apiKey)
put("station_profile_id", stationProfileId)
put("type", "adif")
put("string", toAdif(qso, gridsquare, satName))
}
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
// 无 index.php 的 v1
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
}
/** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
return "<$name:$bytes>$value"
}
return buildString {
append(field("call", qso.call))
append(field("band", "SAT"))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
}
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
if (satName.isNotBlank()) {
append(field("sat_name", satName))
append(field("prop_mode", "SAT"))
}
append("<eor>")
}
}
/** 通用 HTTP 请求(返回 code + body) */
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = TIMEOUT_MS
conn.readTimeout = TIMEOUT_MS
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
if (jsonBody != null) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = if (stream != null) {
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
} else ""
code to body
} catch (e: Exception) {
-1 to (e.message ?: e.javaClass.simpleName)
}
}
private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML 错误页
return try {
val obj = JSONObject(body)
val err = obj.optJSONObject("error")
err?.optString("message")?.ifBlank { body.take(120) }
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
} catch (_: Exception) {
body.take(120)
}
}
private fun utcDate(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d-%02d-%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTime(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d:%02d:%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
private fun utcDateCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d%02d%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTimeCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d%02d%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
}
@@ -0,0 +1,100 @@
/*
* WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
*
* 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
* 由 core/data 用 SharedPreferences 实现。
* 队列上限 500 条(超出丢最旧)。
*/
package com.rtbishop.look4sat.core.domain.wavelog
import org.json.JSONArray
import org.json.JSONObject
/** 存储抽象(SharedPreferences 实现见 core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
val call: String,
val mode: String,
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String,
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
)
class WavelogQueue(private val store: IWavelogQueueStore) {
private val key = "wavelog_queue"
fun all(): List<WavelogQso> {
val raw = store.load()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
WavelogQso(
id = o.getString("id"),
timeUtcMs = o.getLong("timeUtcMs"),
call = o.optString("call"),
mode = o.optString("mode"),
freqTxHz = o.optLong("freqTxHz"),
freqRxHz = o.optLong("freqRxHz"),
satName = o.optString("satName"),
sessionId = o.optString("sessionId"),
uploaded = o.optBoolean("uploaded", false)
)
}
} catch (_: Exception) { emptyList() }
}
@Synchronized
fun add(qso: WavelogQso) {
val list = all().toMutableList()
list.add(0, qso) // 最新在前
if (list.size > 500) list.removeAt(list.size - 1)
save(list)
}
@Synchronized
fun remove(id: String) {
save(all().filter { it.id != id })
}
@Synchronized
fun removeAll(ids: Set<String>) {
save(all().filter { it.id !in ids })
}
/** 标记为已上传(保留在队列, 表格打勾) */
@Synchronized
fun markUploaded(id: String) {
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
}
/** 移除所有已上传条目(可选项, 保持队列精简) */
@Synchronized
fun removeUploaded() {
save(all().filter { !it.uploaded })
}
private fun save(list: List<WavelogQso>) {
val arr = JSONArray()
list.forEach { q ->
arr.put(JSONObject().apply {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("sessionId", q.sessionId)
put("uploaded", q.uploaded)
})
}
store.save(arr.toString())
}
}
@@ -0,0 +1,91 @@
/*
* WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
*
* 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
* → POST /api/v2/qso → 成功移出队列。
* 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
* 确认后带 force=true 重试本批。
*/
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import org.json.JSONObject
sealed class UploadOutcome {
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
data class Done(
val successCount: Int,
val failedCount: Int,
val message: String,
val firstError: String = ""
) : UploadOutcome()
}
class WavelogUploader(
private val settingsRepo: ISettingsRepo,
private val queue: WavelogQueue
) {
// 台站网格缓存(每次上传前刷新; 失败用旧值)
private var cachedStationGrid: String? = null
/** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
val settings = settingsRepo.otherSettings.value
val url = settings.wavelogUrl
val apiKey = settings.wavelogApiKey
val stationId = settings.wavelogStationId
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
}
// 1. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
if (stationGrid.isNullOrBlank()) {
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
}
// 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过)
if (!force) {
val userGrid = userQthGrid()
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
}
}
// 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾)
val entries = queue.all()
var ok = 0
var fail = 0
var firstError = ""
for (qso in entries) {
if (qso.uploaded) { ok++; continue }
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid)
if (result is WavelogResult.Success) {
ok++
queue.markUploaded(qso.id)
} else {
fail++
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
}
}
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
return UploadOutcome.Done(ok, fail, message, firstError)
}
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
val result = WaveLogApi.getStation(url, apiKey, stationId)
if (result is WavelogResult.Success) {
return try {
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
?: cachedStationGrid
} catch (_: Exception) { cachedStationGrid }
}
return cachedStationGrid
}
/** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
private fun userQthGrid(): String? {
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
}
}
@@ -13,12 +13,15 @@ class DopplerFrequencyCalculatorTest {
upHigh: Long = 145_500_000L, upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L, downLow: Long = 435_000_000L,
downHigh: Long? = 435_500_000L, downHigh: Long? = 435_500_000L,
inverted: Boolean = false inverted: Boolean = false,
info: String = "Linear Transponder",
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio( ) = SatRadio(
uuid = "linear", info = "Linear Transponder", isAlive = true, uuid = "linear", info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh, downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh, downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = "LSB", isInverted = inverted, catnum = 12345 uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
) )
private fun fmTransponder() = SatRadio( private fun fmTransponder() = SatRadio(
@@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr)) assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
} }
@Test
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
}
@Test
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test @Test
fun computeUplinkFromDownlink_linear_noDoppler() { fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder() val xpdr = linearTransponder()
@@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest {
assertNull(result) assertNull(result)
} }
@Test
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(435_202_500L, downlink)
}
@Test
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = 435_202_500L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeOffsetRoundTrip_handlesNegativeOffset() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(435_197_500L, downlink)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = downlink!!,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test @Test
fun computeUplinkFromDownlink_invertedTransponder() { fun computeUplinkFromDownlink_invertedTransponder() {
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L) val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
@@ -518,6 +518,5 @@ fun formatFrequency(frequencyHz: Long): String {
if (frequencyHz <= 0) return "---" if (frequencyHz <= 0) return "---"
val mhz = frequencyHz / 1_000_000 val mhz = frequencyHz / 1_000_000
val khz = (frequencyHz % 1_000_000) / 1_000 val khz = (frequencyHz % 1_000_000) / 1_000
val hz = frequencyHz % 1_000 return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
} }
@@ -44,12 +44,18 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
@Serializable @Serializable
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode") data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
@Serializable
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
@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 设置: 默认主菜单页面顺序(底部栏 5 槽)
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "CwDecode", "Map", "Settings") val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog")
@Serializable @Serializable
data object RadarDestination : NavKey data object RadarDestination : NavKey
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
</vector>
@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
<string name="nav_sat">Satelit</string>
<string name="nav_pass">Lintasan</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Pasangan</string>
<string name="nav_roaming">Jelajah</string>
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
<string name="sat_search_clear">Bersihkan</string>
<string name="sat_clear_all">Bersihkan semua</string>
<string name="sat_select_all">Pilih semua</string>
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
<string name="sat_warning_title">Peringatan\!</string>
<string name="sat_warning_message">
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
Tidak masuk akal untuk melacak semuanya sekaligus.
\n\nSelalu coba persempit daftar hanya ke satelit yang
Anda minati melalui pencarian dan pemilih jenis.</string>
<string name="pass_filter_title">Filter lintasan</string>
<string name="pass_filter_elev">Elevasi minimal</string>
<string name="pass_filter_hours">Waktu ke depan</string>
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message">
* WaveLog kini mendukung API v1 dan v2 (terdeteksi otomatis); dialog kesalahan dengan tombol salin
* Frekuensi log cocok dengan panel Doppler; transponder linier menampilkan rentang penuh</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low">%.4f</string>
<string name="radar_link_lowHigh">%.4f - %.4f</string>
<string name="radar_no_link">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
<string name="radar_inverted">Terbalik: %s</string>
<string name="radar_mode">Mode: %s</string>
<string name="radar_string_no">Tidak</string>
<string name="radar_string_yes">Ya</string>
<string name="radar_visible">Terlihat</string>
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
<string name="radar_cw_decoder">Dekoder CW</string>
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title">Look4Sat Pro v%s</string>
<string name="prefs_app_url">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donasi</string>
<string name="prefs_donate_url">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title">F-Droid</string>
<string name="prefs_fdroid_url">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title">GitHub</string>
<string name="prefs_github_url">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title">GPLv3</string>
<string name="prefs_license_url">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Kebijakan Privasi</string>
<string name="prefs_privacy_url">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
<string name="prefs_data_title">Data satelit</string>
<string name="prefs_data_entries">Satelit: %s</string>
<string name="prefs_data_radios">Transceiver: %s</string>
<string name="prefs_data_update">Perbarui</string>
<string name="prefs_data_import">URL Kustom</string>
<string name="prefs_data_clear">Bersihkan</string>
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">Kontrol Radio</string>
<string name="rc_settings_title">Kontrol Radio CAT</string>
<string name="rc_radio_model">Model Radio</string>
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
<string name="rc_tx_name_hint">Nama Radio TX</string>
<string name="rc_rx_name_hint">Nama Radio RX</string>
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
<string name="prefs_net_title">Keluaran data jaringan</string>
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
<string name="prefs_bt_rotator_output_hint">Format data</string>
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
<string name="prefs_bt_frequency_output_hint">Format data</string>
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
<string name="tracking_status">Melacak: %s</string>
<string name="loc_lat_text">Lintang: %s°</string>
<string name="loc_lon_text">Bujur: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Hubungkan</string>
<string name="disconnect_radio">Putuskan</string>
<string name="track_radio">Lacak</string>
<string name="stop_radio">Berhenti</string>
<string name="prefs_wavelog_title">Server log WaveLog</string>
<string name="prefs_wavelog_url_hint">Alamat server</string>
<string name="prefs_wavelog_key_hint">Kunci API</string>
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
<string name="wavelog_empty">Belum ada entri log</string>
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
<string name="wavelog_col_freq">Frek.</string>
<string name="wavelog_col_sat">Satelit</string>
<string name="wavelog_col_call">Panggilan</string>
<string name="wavelog_col_uploaded">Terkirim</string>
<string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string>
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
<string name="prefs_ui_sub_title">Menu lainnya</string>
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
<string name="prefs_highlight_title">Sorotan elevasi</string>
<string name="prefs_highlight_low">Rendah &lt; %1$d°</string>
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
\n* Alexandru Csete (Gpredict)
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl dan kontributor Robot36!
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
</resources>
@@ -0,0 +1,237 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
<string name="nav_sat">Satelit</string>
<string name="nav_pass">Lintasan</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Pasangan</string>
<string name="nav_roaming">Jelajah</string>
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
<string name="sat_search_clear">Bersihkan</string>
<string name="sat_clear_all">Bersihkan semua</string>
<string name="sat_select_all">Pilih semua</string>
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
<string name="sat_warning_title">Peringatan\!</string>
<string name="sat_warning_message">
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
Tidak masuk akal untuk melacak semuanya sekaligus.
\n\nSelalu coba persempit daftar hanya ke satelit yang
Anda minati melalui pencarian dan pemilih jenis.</string>
<string name="pass_filter_title">Filter lintasan</string>
<string name="pass_filter_elev">Elevasi minimal</string>
<string name="pass_filter_hours">Waktu ke depan</string>
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message">
* WaveLog kini mendukung API v1 dan v2 (terdeteksi otomatis); dialog kesalahan dengan tombol salin
* Frekuensi log cocok dengan panel Doppler; transponder linier menampilkan rentang penuh</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low">%.4f</string>
<string name="radar_link_lowHigh">%.4f - %.4f</string>
<string name="radar_no_link">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
<string name="radar_inverted">Terbalik: %s</string>
<string name="radar_mode">Mode: %s</string>
<string name="radar_string_no">Tidak</string>
<string name="radar_string_yes">Ya</string>
<string name="radar_visible">Terlihat</string>
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
<string name="radar_cw_decoder">Dekoder CW</string>
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title">Look4Sat Pro v%s</string>
<string name="prefs_app_url">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donasi</string>
<string name="prefs_donate_url">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title">F-Droid</string>
<string name="prefs_fdroid_url">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title">GitHub</string>
<string name="prefs_github_url">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title">GPLv3</string>
<string name="prefs_license_url">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Kebijakan Privasi</string>
<string name="prefs_privacy_url">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
<string name="prefs_data_title">Data satelit</string>
<string name="prefs_data_entries">Satelit: %s</string>
<string name="prefs_data_radios">Transceiver: %s</string>
<string name="prefs_data_update">Perbarui</string>
<string name="prefs_data_import">URL Kustom</string>
<string name="prefs_data_clear">Bersihkan</string>
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">Kontrol Radio</string>
<string name="rc_settings_title">Kontrol Radio CAT</string>
<string name="rc_radio_model">Model Radio</string>
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
<string name="rc_tx_name_hint">Nama Radio TX</string>
<string name="rc_rx_name_hint">Nama Radio RX</string>
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
<string name="prefs_net_title">Keluaran data jaringan</string>
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
<string name="prefs_bt_rotator_output_hint">Format data</string>
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
<string name="prefs_bt_frequency_output_hint">Format data</string>
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
<string name="tracking_status">Melacak: %s</string>
<string name="loc_lat_text">Lintang: %s°</string>
<string name="loc_lon_text">Bujur: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Hubungkan</string>
<string name="disconnect_radio">Putuskan</string>
<string name="track_radio">Lacak</string>
<string name="stop_radio">Berhenti</string>
<string name="prefs_wavelog_title">Server log WaveLog</string>
<string name="prefs_wavelog_url_hint">Alamat server</string>
<string name="prefs_wavelog_key_hint">Kunci API</string>
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
<string name="wavelog_empty">Belum ada entri log</string>
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
<string name="wavelog_col_freq">Frek.</string>
<string name="wavelog_col_sat">Satelit</string>
<string name="wavelog_col_call">Panggilan</string>
<string name="wavelog_col_uploaded">Terkirim</string>
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
<string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string>
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
<string name="prefs_ui_sub_title">Menu lainnya</string>
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
<string name="prefs_highlight_title">Sorotan elevasi</string>
<string name="prefs_highlight_low">Rendah &lt; %1$d°</string>
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
\n* Alexandru Csete (Gpredict)
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl dan kontributor Robot36!
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
</resources>
@@ -13,6 +13,8 @@
<string name="nav_cw">CW Dekoder</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>
<string name="nav_more">Daha Fazla</string>
<string name="nav_log">Günlük</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string> <string name="sat_type_hint">Uydu türü: %s</string>
@@ -52,8 +54,8 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string> \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Transponder CW kod çözücü paneli artık geliştirilmiş kod çözme motorunu canlı şelale (waterfall) görüntüsüyle kullanıyor * WaveLog artık hem API v1 hem v2 destekliyor (otomatik algılama); kopyalama butonlu hata penceresi
* CW kod çözme sayfaları aynı motoru paylaşıyor: gezinme sayfası ve transponder paneli</string> * Günlük frekansları Doppler paneliyle eşleşiyor; doğrusal transponderler tam aralığı gösteriyor</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Geri</string> <string name="radar_back">Geri</string>
@@ -118,7 +120,7 @@
<string name="prefs_loc_title">İstasyon konumu</string> <string name="prefs_loc_title">İstasyon konumu</string>
<string name="prefs_loc_gps_title">GPS</string> <string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string> <string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
<string name="prefs_loc_input_title">Manuel giriş</string> <string name="prefs_loc_input_title">Manuel giriş</string>
<string name="prefs_loc_input_error">Geçersiz konum girildi</string> <string name="prefs_loc_input_error">Geçersiz konum girildi</string>
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
@@ -138,6 +140,7 @@
<string name="prefs_data_clear">Temizle</string> <string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string> <string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string> <string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string> <string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string> <string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
@@ -178,9 +181,47 @@
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string> <string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string> <string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="tracking_status">İzleniyor: %s</string>
<string name="loc_lat_text">Enlem: %s°</string>
<string name="loc_lon_text">Boylam: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Bağlan</string>
<string name="disconnect_radio">Bağlantıyı Kes</string>
<string name="track_radio">Takip</string>
<string name="stop_radio">Dur</string>
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
<string name="prefs_wavelog_key_hint">API anahtarı</string>
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
<string name="prefs_wavelog_upload">Şimdi yükle</string>
<string name="wavelog_title">Günlük</string>
<string name="wavelog_select_transponder">Transponder seç</string>
<string name="wavelog_call_hint">Çağrı işareti</string>
<string name="wavelog_mode_hint">Mod</string>
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
<string name="wavelog_ignore">Yoksay ve yükle</string>
<string name="wavelog_cancel">İptal</string>
<string name="wavelog_copy">Kopyala</string>
<string name="wavelog_delete_undo">Geri al</string>
<string name="wavelog_col_time">Zaman</string>
<string name="wavelog_col_freq">Frekans</string>
<string name="wavelog_col_sat">Uydu</string>
<string name="wavelog_col_call">Çağrı</string>
<string name="wavelog_col_uploaded">Gönderildi</string>
<string name="wavelog_ungrouped">Gruplanmamış</string>
<string name="prefs_other_title">Diğer ayarlar</string> <string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_ui_title">UI Ayarları</string> <string name="prefs_ui_title">UI Ayarları</string>
<string name="prefs_ui_order_title">Sayfa sırası</string> <string name="prefs_ui_order_title">Sayfa sırası</string>
<string name="prefs_ui_main_title">Ana menü (alt çubuk)</string>
<string name="prefs_ui_sub_title">Daha fazla menü</string>
<string name="prefs_ui_move_out">Daha fazla menüye taşı</string>
<string name="prefs_ui_move_back">Ana menüye geri taşı</string>
<string name="prefs_ui_order_locked">Sabit (taşınamaz)</string>
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string> <string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string> <string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string> <string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
@@ -14,6 +14,8 @@
<string name="nav_cw">CW解码</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>
<string name="nav_more">更多</string>
<string name="nav_log">日志</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string> <string name="sat_type_hint">类型:%s</string>
@@ -42,8 +44,8 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string> <string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string> <string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* 转发器 CW 解码面板已接入增强解码引擎,带实时瀑布图显示 * WaveLog 同时支持 API v1 和 v2(自动识别); 错误弹窗带一键复制
* CW 解码入口共用同一引擎:导航页与转发器面板</string> * 日志频率与多普勒面板一致; 线性转发器显示完整频段区间</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">后退</string> <string name="radar_back">后退</string>
@@ -100,7 +102,7 @@
<string name="prefs_loc_title">站位</string> <string name="prefs_loc_title">站位</string>
<string name="prefs_loc_gps_title">GPS 定位</string> <string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string> <string name="prefs_loc_gps_error">无法获取位置,请检查定位权限和GPS/网络信号</string>
<string name="prefs_loc_input_title">输入位置</string> <string name="prefs_loc_input_title">输入位置</string>
<string name="prefs_loc_input_error">输入的位置无效</string> <string name="prefs_loc_input_error">输入的位置无效</string>
<string name="prefs_loc_qth_title">QTH 网格</string> <string name="prefs_loc_qth_title">QTH 网格</string>
@@ -120,6 +122,7 @@
<string name="prefs_data_clear">清空</string> <string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string> <string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string> <string name="prefs_data_update_success">更新成功</string>
<string name="prefs_data_update_failed">更新失败,请检查网络</string>
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string> <string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string> <string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
@@ -161,9 +164,47 @@
<string name="rc_rx_name_hint">接收电台名称</string> <string name="rc_rx_name_hint">接收电台名称</string>
<string name="rc_enable_switch">启用 CAT 控制</string> <string name="rc_enable_switch">启用 CAT 控制</string>
<string name="tracking_status">跟踪: %s</string>
<string name="loc_lat_text">纬度: %s°</string>
<string name="loc_lon_text">经度: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">连接</string>
<string name="disconnect_radio">断开</string>
<string name="track_radio">跟踪</string>
<string name="stop_radio">停止</string>
<string name="prefs_wavelog_title">WaveLog 日志服务器</string>
<string name="prefs_wavelog_url_hint">服务器地址</string>
<string name="prefs_wavelog_key_hint">API 密钥</string>
<string name="prefs_wavelog_station_hint">站点 ID</string>
<string name="prefs_wavelog_auto">自动上传</string>
<string name="prefs_wavelog_test">测试连接</string>
<string name="prefs_wavelog_upload">手动上传</string>
<string name="wavelog_title">日志</string>
<string name="wavelog_select_transponder">选择转发器</string>
<string name="wavelog_call_hint">对方呼号</string>
<string name="wavelog_mode_hint">模式</string>
<string name="wavelog_saved">已存入本地日志</string>
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
<string name="wavelog_empty">暂无日志记录</string>
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
<string name="wavelog_ignore">忽略并上传</string>
<string name="wavelog_cancel">取消</string>
<string name="wavelog_copy">复制</string>
<string name="wavelog_delete_undo">撤销</string>
<string name="wavelog_col_time">时间</string>
<string name="wavelog_col_freq">频率</string>
<string name="wavelog_col_sat">卫星</string>
<string name="wavelog_col_call">呼号</string>
<string name="wavelog_col_uploaded">已传</string>
<string name="wavelog_ungrouped">未分组</string>
<string name="prefs_other_title">其他设置</string> <string name="prefs_other_title">其他设置</string>
<string name="prefs_ui_title">UI设置</string> <string name="prefs_ui_title">UI设置</string>
<string name="prefs_ui_order_title">页面顺序</string> <string name="prefs_ui_order_title">页面顺序</string>
<string name="prefs_ui_main_title">主菜单(底部栏)</string>
<string name="prefs_ui_sub_title">更多菜单</string>
<string name="prefs_ui_move_out">移入更多菜单</string>
<string name="prefs_ui_move_back">移回主菜单</string>
<string name="prefs_ui_order_locked">固定(不可移动)</string>
<string name="prefs_ui_order_reset">重置顺序</string> <string name="prefs_ui_order_reset">重置顺序</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string> <string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string> <string name="prefs_other_switch_update">启用卫星数据自动更新</string>
@@ -15,6 +15,8 @@
<string name="nav_cw">CW Decoder</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>
<string name="nav_more">More</string>
<string name="nav_log">Log</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string> <string name="sat_type_hint">Type: %s</string>
@@ -54,8 +56,8 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Transponder CW decoder panel now uses the enhanced decoding engine with a live waterfall display * WaveLog now supports both API v1 and v2 (auto-detected); error dialog with copy button
* CW decoding pages share the same engine: navigation page and transponder panel</string> * Log frequencies match the Doppler panel; linear transponders show the full range</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
@@ -130,7 +132,7 @@
<string name="prefs_loc_title">Station position</string> <string name="prefs_loc_title">Station position</string>
<string name="prefs_loc_gps_title">GPS</string> <string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Check your location permissions</string> <string name="prefs_loc_gps_error">Unable to get location - check permission and GPS/network signal</string>
<string name="prefs_loc_input_title">Input</string> <string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Invalid location entered</string> <string name="prefs_loc_input_error">Invalid location entered</string>
<string name="prefs_loc_qth_title">QTH</string> <string name="prefs_loc_qth_title">QTH</string>
@@ -150,6 +152,7 @@
<string name="prefs_data_clear">Clear</string> <string name="prefs_data_clear">Clear</string>
<string name="prefs_data_clear_success">Data was cleared successfully</string> <string name="prefs_data_clear_success">Data was cleared successfully</string>
<string name="prefs_data_update_success">Update completed successfully</string> <string name="prefs_data_update_success">Update completed successfully</string>
<string name="prefs_data_update_failed">Update failed - check your network</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
@@ -192,9 +195,47 @@
<string name="prefs_bt_perm_error">Check your bluetooth permission</string> <string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_net_perm_error">Check your network permission</string> <string name="prefs_net_perm_error">Check your network permission</string>
<string name="tracking_status">Tracking: %s</string>
<string name="loc_lat_text">Lat: %s°</string>
<string name="loc_lon_text">Lon: %s°</string>
<string name="loc_qth_text">Qth: %s</string>
<string name="connect_radio">Connect</string>
<string name="disconnect_radio">Disconnect</string>
<string name="track_radio">Track</string>
<string name="stop_radio">Stop</string>
<string name="prefs_wavelog_title">WaveLog log server</string>
<string name="prefs_wavelog_url_hint">Server address</string>
<string name="prefs_wavelog_key_hint">API key</string>
<string name="prefs_wavelog_station_hint">Station ID</string>
<string name="prefs_wavelog_auto">Auto upload</string>
<string name="prefs_wavelog_test">Test connection</string>
<string name="prefs_wavelog_upload">Upload now</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Select transponder</string>
<string name="wavelog_call_hint">Callsign</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Saved to local log</string>
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
<string name="wavelog_empty">No log entries yet</string>
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
<string name="wavelog_ignore">Ignore and upload</string>
<string name="wavelog_cancel">Cancel</string>
<string name="wavelog_copy">Copy</string>
<string name="wavelog_delete_undo">Undo</string>
<string name="wavelog_col_time">Time</string>
<string name="wavelog_col_freq">Freq</string>
<string name="wavelog_col_sat">Satellite</string>
<string name="wavelog_col_call">Call</string>
<string name="wavelog_col_uploaded">Sent</string>
<string name="wavelog_ungrouped">Ungrouped</string>
<string name="prefs_other_title">Other settings</string> <string name="prefs_other_title">Other settings</string>
<string name="prefs_ui_title">UI Settings</string> <string name="prefs_ui_title">UI Settings</string>
<string name="prefs_ui_order_title">Page order</string> <string name="prefs_ui_order_title">Page order</string>
<string name="prefs_ui_main_title">Main menu (bottom bar)</string>
<string name="prefs_ui_sub_title">More menu</string>
<string name="prefs_ui_move_out">Move to more menu</string>
<string name="prefs_ui_move_back">Move back to main menu</string>
<string name="prefs_ui_order_locked">Fixed (cannot be moved)</string>
<string name="prefs_ui_order_reset">Reset order</string> <string name="prefs_ui_order_reset">Reset order</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string> <string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string> <string name="prefs_other_switch_update">Enable automatic data update</string>
@@ -181,7 +181,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = "Microphone permission is required for CW decoding", text = stringResource(R.string.cw_mic_permission),
color = Color.White, color = Color.White,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp) modifier = Modifier.padding(horizontal = 24.dp)
@@ -190,7 +190,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
Button(onClick = { Button(onClick = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) { }) {
Text("Grant permission") Text(stringResource(R.string.cw_grant_permission))
} }
if (permanentlyDenied) { 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">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="premium">Get Premium Version</string>
<string name="tap_back_again_to_close">Tap Back again to close the app</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="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> </resources>
@@ -44,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -57,6 +58,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.domain.repository.MutualPassData
@@ -94,15 +96,20 @@ fun MutualScreen(
) )
}, },
bottomInfo = { bottomInfo = {
if (state.mutualPasses.isNotEmpty()) {
Text( Text(
text = "找到 ${state.mutualPasses.size} 个匹配", text = mutualStatusText(state),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
) )
}
}, },
endAction = { endAction = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (state.isCalculating) { if (state.isCalculating) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier
@@ -111,6 +118,8 @@ fun MutualScreen(
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} }
MutualStatusChip(state)
}
} }
) )
} }
@@ -187,8 +196,6 @@ private fun MutualContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
MatchIntroCard()
if (isVertical) { if (isVertical) {
StationInputCard( StationInputCard(
title = "你的位置", title = "你的位置",
@@ -304,25 +311,50 @@ private fun MutualContent(
} }
@Composable @Composable
private fun MatchIntroCard(modifier: Modifier = Modifier) { private fun MutualStatusChip(state: MutualUiState) {
ElevatedCard(modifier = modifier.fillMaxWidth()) { val colorScheme = MaterialTheme.colorScheme
Column( val containerColor = when {
modifier = Modifier.padding(12.dp), state.isCalculating -> colorScheme.primaryContainer
verticalArrangement = Arrangement.spacedBy(4.dp) state.errorMessage != null -> colorScheme.errorContainer
state.mutualPasses.isNotEmpty() -> colorScheme.primary
state.hasSearched -> colorScheme.surfaceVariant
else -> colorScheme.surfaceVariant
}
val contentColor = when {
state.isCalculating -> colorScheme.onPrimaryContainer
state.errorMessage != null -> colorScheme.onErrorContainer
state.mutualPasses.isNotEmpty() -> colorScheme.onPrimary
else -> colorScheme.onSurfaceVariant
}
Surface(
color = containerColor,
contentColor = contentColor,
shape = MaterialTheme.shapes.small
) { ) {
Text( Text(
text = "位置匹配", text = mutualStatusChipText(state),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary maxLines = 1,
) modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
Text(
text = "输入双方位置,查找共同可见的卫星过境窗口",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
private fun mutualStatusText(state: MutualUiState): String = when {
state.isCalculating -> "正在计算双方共同可见窗口…"
state.errorMessage != null -> "请修正提示后重新查询"
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线"
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试"
else -> "输入双方位置后点击查询"
}
private fun mutualStatusChipText(state: MutualUiState): String = when {
state.isCalculating -> "计算中"
state.errorMessage != null -> "有错误"
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配"
state.hasSearched -> "无匹配"
else -> "待查询"
} }
@Composable @Composable
@@ -49,6 +49,7 @@ data class MutualUiState(
val hoursAhead: Int = 24, val hoursAhead: Int = 24,
val mutualPasses: List<MutualPass> = emptyList(), val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false, val isCalculating: Boolean = false,
val hasSearched: Boolean = false,
val selectedPassIndex: Int = -1, val selectedPassIndex: Int = -1,
val errorMessage: String? = null val errorMessage: String? = null
) )
@@ -167,7 +168,15 @@ class MutualViewModel(
return return
} }
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) } _uiState.update {
it.copy(
isCalculating = true,
hasSearched = true,
errorMessage = null,
mutualPasses = emptyList(),
selectedPassIndex = -1
)
}
viewModelScope.launch { viewModelScope.launch {
val time = System.currentTimeMillis() val time = System.currentTimeMillis()
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -162,7 +163,10 @@ class PassesViewModel(
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz } return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
} }
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace // Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace.
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> { private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
@@ -171,7 +175,7 @@ class PassesViewModel(
val result = LinkedHashMap<String, Pair<String, String>>() val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times // DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) { if (passes.any { it.isDeepSpace }) {
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis()) val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--" val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--" val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set result["DeepSpace (period >225min)"] = rise to set
@@ -180,7 +184,7 @@ class PassesViewModel(
if (pass.isDeepSpace) continue if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime)) val label = sdfDate.format(Date(pass.aosTime))
if (label in result) continue if (label in result) continue
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime) val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--" val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--" val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result[label] = rise to set result[label] = rise to set
@@ -188,6 +192,16 @@ class PassesViewModel(
return result return result
} }
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
return Calendar.getInstance(tz, Locale.getDefault()).apply {
timeInMillis = timeMillis
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> { private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = dateFormat(tz) val sdfDate = dateFormat(tz)
@@ -0,0 +1,465 @@
/*
* LogTab.kt — 雷达页「Log」标签(4.5.2 WaveLog 日志)。
*
* 布局: 转发器列表(点开选一个, 边看频率边填) → 呼号输入回车存本地 →
* 下方本地记录列表(时间/频率/呼号)。记录支持自绘左滑删除:
* 滑动中显示黄色垃圾桶, 滑过 75% 变撤销键, 5 秒内不点撤销则删除。
*
* 回车只存本地(WavelogQueue), 上传由 10 分钟周期/手动触发(防抄收错误)。
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
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.ui.layout.onSizeChanged
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import kotlinx.coroutines.delay
import java.util.Calendar
import java.util.TimeZone
import java.util.UUID
import kotlin.math.roundToInt
private val WaveLogYellow = Color(0xFFFFC107)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogTab(
transceivers: List<SatRadio>,
orbitalPos: OrbitalPos?,
satelliteName: String,
queue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
txBaseFrequencyHz: Long? = null,
aosTimeMs: Long = 0L,
modifier: Modifier = Modifier
) {
var selectedUuid by remember { mutableStateOf<String?>(null) }
var refreshTick by remember { mutableIntStateOf(0) }
val entries = remember(refreshTick) { queue.all() }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (!wavelogConfigured) {
Text(
text = stringResource(id = R.string.wavelog_need_config),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 转发器选择(下拉选择框): 选中后底下出现输入区
// 选中项按 uuid 从 transceivers 实时派生(多普勒频率每秒更新, 频率显示随之刷新)
var menuExpanded by remember { mutableStateOf(false) }
val selectedRadio = transceivers.firstOrNull { it.uuid == selectedUuid }
ExposedDropdownMenuBox(
expanded = menuExpanded,
onExpandedChange = { menuExpanded = it }
) {
OutlinedTextField(
value = selectedRadio?.info ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(id = R.string.wavelog_select_transponder)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
transceivers.forEach { radio ->
DropdownMenuItem(
text = {
Text(
text = radio.info,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = {
selectedUuid = radio.uuid
menuExpanded = false
}
)
}
}
}
// 选中转发器后: 输入区(实时频率 + 呼号 + 模式)
selectedRadio?.let { radio ->
ExpandedLogInput(
radio = radio,
orbitalPos = orbitalPos,
satelliteName = satelliteName,
queue = queue,
showToast = showToast,
txBaseFrequencyHz = txBaseFrequencyHz,
aosTimeMs = aosTimeMs,
onSaved = { refreshTick++ }
)
}
// 本地记录列表(时间/频率/呼号)
Text(
text = stringResource(id = R.string.wavelog_saved),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (entries.isEmpty()) {
Text(
text = stringResource(id = R.string.wavelog_empty),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
entries.forEach { entry ->
SwipeDeleteRow(
onDelete = {
queue.remove(entry.id)
refreshTick++
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Text(
text = formatLocalTime(entry.timeUtcMs),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(64.dp)
)
Text(
text = formatFrequency(entry.freqTxHz),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(76.dp)
)
Text(
text = entry.call,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
/** 转发器展开输入区: 实时频率 + 呼号(回车存本地) + 模式修正 */
@Composable
private fun ExpandedLogInput(
radio: SatRadio,
orbitalPos: OrbitalPos?,
satelliteName: String,
queue: WavelogQueue,
showToast: (String) -> Unit,
txBaseFrequencyHz: Long? = null,
aosTimeMs: Long = 0L,
onSaved: () -> Unit
) {
var callsign by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
val savedMsg = stringResource(id = R.string.wavelog_saved)
fun submit() {
val call = callsign.trim().uppercase()
if (call.length < 3) return
// 频率直接取转发器栏的数字(radio 每秒多普勒修正, 回车那一秒的值)
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
queue.add(
WavelogQso(
id = UUID.randomUUID().toString(),
timeUtcMs = System.currentTimeMillis(),
call = call,
mode = mode.trim().ifBlank { "FM" }.uppercase(),
freqTxHz = tx,
freqRxHz = rx,
satName = satelliteName,
sessionId = buildSessionId(satelliteName, aosTimeMs)
)
)
callsign = ""
onSaved()
showToast(savedMsg)
}
// 频率显示与转发器栏完全一致: TX 行 + RX 行(radio 来自每秒多普勒重算, 实时刷新)
val txForDisplay = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rxForDisplay = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val upLow = radio.uplinkLow
val upHigh = radio.uplinkHigh
val downLow = radio.downlinkLow
val downHigh = radio.downlinkHigh
val isLinearRange = upLow != null && upHigh != null && upLow != upHigh
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
// TX 行
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "TX: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${formatFrequency(txForDisplay)} MHz",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
if (isLinearRange) {
Text(
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// RX 行(与转发器栏 RX 一致)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "RX: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${formatFrequency(rxForDisplay)} MHz",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
if (downLow != null && downHigh != null && downLow != downHigh) {
Text(
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
OutlinedTextField(
value = callsign,
onValueChange = { callsign = it.take(12) },
label = { Text(stringResource(id = R.string.wavelog_call_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = mode,
onValueChange = { mode = it.take(8) },
label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
}
}
/**
* 自绘左滑删除行(用户定制交互):
* 左滑 → 右侧黄色垃圾桶; 滑过 75% → 变撤销键(待删除, 不立即删);
* 5 秒倒计时内点撤销恢复, 不点自动删除。
*/
@Composable
internal fun SwipeDeleteRow(
onDelete: () -> Unit,
content: @Composable () -> Unit
) {
var offsetX by remember { mutableStateOf(0f) }
var pending by remember { mutableStateOf(false) }
var countdown by remember { mutableIntStateOf(5) }
var rowWidth by remember { mutableIntStateOf(0) }
val threshold = rowWidth * 0.75f
// 平滑弹回/滑入(动画)
val animatedOffset by animateFloatAsState(
targetValue = offsetX,
animationSpec = if (pending) tween(200) else spring(dampingRatio = Spring.DampingRatioNoBouncy),
label = "swipeOffset"
)
// 倒计时: 进入待删除后 5 秒自动删
LaunchedEffect(pending) {
if (pending) {
countdown = 5
while (countdown > 0) {
delay(1000)
countdown--
}
if (pending) {
onDelete()
pending = false
offsetX = 0f
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { rowWidth = it.width }
) {
// 背景层(右侧图标: 垃圾桶 / 撤销+倒计时)
Box(
modifier = Modifier
.matchParentSize()
.background(WaveLogYellow.copy(alpha = 0.25f)),
contentAlignment = Alignment.CenterEnd
) {
if (pending) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "$countdown",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = WaveLogYellow
)
Text(
text = stringResource(id = R.string.wavelog_delete_undo),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = WaveLogYellow,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(WaveLogYellow.copy(alpha = 0.15f))
.clickable {
pending = false
offsetX = 0f
}
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
} else {
// 垃圾桶(滑动过程中一直显示, 黄色)
Text(
text = "🗑",
fontSize = 20.sp,
modifier = Modifier.padding(end = 16.dp)
)
}
}
// 内容层(跟手滑动): 背景在 offset 内侧(随内容移动)!
// 平时盖住垃圾桶, 左滑时内容带背景移开 → 右侧露出黄色垃圾桶/撤销倒计时
Box(
modifier = Modifier
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.background(MaterialTheme.colorScheme.surfaceContainer)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { if (pending) { pending = false } },
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
},
onDragEnd = {
if (offsetX <= -threshold) {
pending = true
countdown = 5
} else {
offsetX = 0f
}
},
onDragCancel = { offsetX = 0f }
)
}
) {
content()
}
}
}
private fun formatLocalTime(utcMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getDefault())
cal.timeInMillis = utcMs
return "%02d:%02d".format(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
}
/** 场次 ID: 卫星名-AOS 时间戳(UTC yyyyMMdd-HHmm); 无 AOS 时用回车时刻 */
private fun buildSessionId(satName: String, aosTimeMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = if (aosTimeMs > 0) aosTimeMs else System.currentTimeMillis()
val stamp = "%04d%02d%02d-%02d%02d".format(
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
return "$satName-$stamp"
}
@@ -61,6 +61,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
@@ -78,6 +81,8 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) { private enum class RadarPage(val title: String) {
Transceivers("Transceivers"), Transceivers("Transceivers"),
Log("Log"),
Calculator("Calculator"),
Sstv("SSTV") Sstv("SSTV")
} }
@@ -119,9 +124,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted))
} }
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = { // WaveLog(4.5.2): 自动上传 — 每 10 分钟重试本地队列(开关开启时)
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) val wavelogUploader = remember { container.provideWavelogUploader() }
}) LaunchedEffect(Unit) {
while (true) {
delay(10 * 60 * 1000L)
val s = container.settingsRepo.otherSettings.value
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
// 网格不一致时静默跳过(留给手动上传确认), 其余失败自动下次重试
val outcome = wavelogUploader.uploadQueue()
if (outcome is UploadOutcome.NeedConfirm) {
// 跳过本次; 用户可在设置页手动上传并确认
}
}
}
}
RadarScreen(
uiState,
viewModel::onAction,
navigateUpAndClearMutual,
mutualData,
{ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) },
wavelogQueue = container.wavelogQueue,
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
},
showToast = { msg -> container.provideShowToast()(msg) }
)
} }
@Composable @Composable
@@ -130,7 +159,10 @@ private fun RadarScreen(
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
mutualData: MutualPassData, mutualData: MutualPassData,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = { val addToCalendar: () -> Unit = {
@@ -184,11 +216,11 @@ private fun RadarScreen(
} }
if (isVertical) { if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
} }
} }
} }
@@ -199,18 +231,37 @@ private fun PagerCard(
uiState: RadarState, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val pages = RadarPage.entries val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
add(RadarPage.Log)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size }) val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
val lastPage = pages.lastIndex
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
}
ElevatedCard(modifier = modifier) { ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page -> pages.forEachIndexed { index, page ->
Tab( Tab(
selected = pagerState.currentPage == index, selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) } text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
) )
@@ -222,11 +273,16 @@ private fun PagerCard(
) { pageIndex -> ) { pageIndex ->
when (pages[pageIndex]) { when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage( RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Calculator -> CalculatorPage(
transceivers = uiState.transceivers.transmitters, transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid, selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos, orbitalPos = uiState.orbitalPos,
cw = uiState.cw, cw = uiState.cw,
radioControl = uiState.radioControl,
onAction = onAction, onAction = onAction,
requestMicPermission = requestMicPermission requestMicPermission = requestMicPermission
) )
@@ -236,6 +292,16 @@ private fun PagerCard(
onAction = onAction, onAction = onAction,
requestMicPermission = requestMicPermission requestMicPermission = requestMicPermission
) )
RadarPage.Log -> LogTab(
transceivers = uiState.transceivers.transmitters,
orbitalPos = uiState.orbitalPos,
satelliteName = uiState.currentPass?.name ?: "",
queue = wavelogQueue,
wavelogConfigured = wavelogConfigured,
showToast = showToast,
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
aosTimeMs = uiState.currentPass?.aosTime ?: 0L
)
} }
} }
} }
@@ -43,7 +43,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -90,11 +89,8 @@ import kotlin.time.Duration.Companion.milliseconds
fun TransceiversPage( fun TransceiversPage(
transceivers: List<SatRadio>, transceivers: List<SatRadio>,
selectedUuid: String?, selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState, radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (transceivers.isEmpty()) { if (transceivers.isEmpty()) {
@@ -117,11 +113,8 @@ fun TransceiversPage(
TransceiverItem( TransceiverItem(
radio = radio, radio = radio,
isExpanded = isExpanded, isExpanded = isExpanded,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl, radioControl = radioControl,
onAction = onAction, onAction = onAction,
requestMicPermission = requestMicPermission,
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) } onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
) )
} }
@@ -129,6 +122,87 @@ fun TransceiversPage(
} }
} }
@Composable
fun CalculatorPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
?: calculatorTransceivers.firstOrNull()
}
if (selectedTransceiver == null) {
EmptyTransceiversContent(modifier)
return
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (calculatorTransceivers.size > 1) {
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
item {
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
}
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
item {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable @Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) { private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) { Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -156,11 +230,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
private fun TransceiverItem( private fun TransceiverItem(
radio: SatRadio, radio: SatRadio,
isExpanded: Boolean, isExpanded: Boolean,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState, radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
onToggle: () -> Unit onToggle: () -> Unit
) { ) {
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
@@ -201,11 +272,8 @@ private fun TransceiverItem(
) )
} }
// Title with mode // Title with mode
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
val fullTitle = "$title ($mode)"
Text( Text(
text = fullTitle, text = transceiverTitle(radio),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -253,11 +321,8 @@ private fun TransceiverItem(
) { ) {
ExpandedRadioControl( ExpandedRadioControl(
radio = radio, radio = radio,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl, radioControl = radioControl,
onAction = onAction, onAction = onAction
requestMicPermission = requestMicPermission
) )
} }
@@ -326,11 +391,8 @@ private fun UnifiedFrequencyRow(
@Composable @Composable
private fun ExpandedRadioControl( private fun ExpandedRadioControl(
radio: SatRadio, radio: SatRadio,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState, radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -439,23 +501,6 @@ private fun ExpandedRadioControl(
} }
} }
// Doppler frequency calculator (linear transponders only)
DopplerFrequencyCalculator(
transponder = radio,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
// CW decoder panel (linear transponders only)
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
// Control buttons // Control buttons
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) { if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
@@ -573,7 +618,7 @@ private fun DopplerFrequencyCalculator(
}, },
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -669,8 +714,8 @@ private fun CwDecoderPanel(
} }
AnimatedVisibility(visible = cw.isExpanded) { AnimatedVisibility(visible = cw.isExpanded) {
// Morse Expert 引擎(4.5.0 移植): 小布局 cw_panel_main(瀑布图 + 解码文本) // PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
// 自带 Kotlin 贝叶斯引擎(CwDecoder)不再用于此面板(代码保留作回退) // Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
val context = LocalContext.current val context = LocalContext.current
val activity = remember { context as? Activity } val activity = remember { context as? Activity }
val controller = remember { MainActivity() } val controller = remember { MainActivity() }
@@ -680,7 +725,6 @@ private fun CwDecoderPanel(
var initialized by remember { mutableStateOf(false) } var initialized by remember { mutableStateOf(false) }
var listening by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) }
// 生命周期: 展开即绑定视图(不改宿主窗口); 收起/离开自动释放
DisposableEffect(Unit) { DisposableEffect(Unit) {
if (activity != null) { if (activity != null) {
controller.onCreate(activity, rootView, false) controller.onCreate(activity, rootView, false)
@@ -690,18 +734,17 @@ private fun CwDecoderPanel(
controller.onDestroy() controller.onDestroy()
} }
} }
// 权限已授予(首次进入/授权回调统一入口): 初始化引擎 + 启动录音
LaunchedEffect(cw.hasPermission) { LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !initialized) { if (cw.hasPermission && !initialized) {
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心 controller.onPermissionGranted()
controller.onResume() // 立即启动录音 controller.onResume()
initialized = true initialized = true
listening = true listening = true
} }
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Control buttons row(映射到控制器)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -744,7 +787,6 @@ private fun CwDecoderPanel(
} }
} }
// 瀑布图 + 解码文本(原版布局迷你版, 适配容器)
AndroidView( AndroidView(
factory = { rootView }, factory = { rootView },
modifier = Modifier modifier = Modifier
@@ -771,5 +813,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
) )
} }
private fun transceiverTitle(radio: SatRadio): String {
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
return "$title ($mode)"
}
private val FREQ_ADJUSTMENTS = private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k") listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
@@ -0,0 +1,236 @@
/*
* WavelogLogScreen.kt — 「日志」页面(更多菜单, 4.5.2 修复)。
*
* 表格形式展示本地存储的日志: 时间 | 频率 | 卫星 | 呼号 | 已上传(✓)。
* 完整格子线(表头横线 + 行横线 + 列竖线 + 外边框); 行复用左滑删除。
*/
package com.rtbishop.look4sat.feature.radar
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.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import java.util.Calendar
import java.util.TimeZone
private val CheckGreen = Color(0xFF4CAF50)
private val GridLineColor = Color(0xFF3A3A3A)
@Composable
fun WavelogLogScreen(
queue: WavelogQueue,
modifier: Modifier = Modifier
) {
var refreshTick by remember { mutableIntStateOf(0) }
val entries = remember(refreshTick) { queue.all() }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
// 表格外边框(左右 + 上下由表头/行底线构成)
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
// 表头
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
HeaderCell(stringResource(id = R.string.wavelog_col_time), 72.dp)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_freq), 64.dp)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_sat), 0.dp, weight = 1.2f)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_call), 0.dp, weight = 1f)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_uploaded), 44.dp)
}
// 表头横线
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
// 表头与数据区留白
Box(modifier = Modifier.height(8.dp))
if (entries.isEmpty()) {
Text(
text = stringResource(id = R.string.wavelog_empty),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp)
)
} else {
// 按场次分组(sessionId = 卫星名-AOS 时间戳); 空 sessionId 归"未分组"放最后
val groups = entries.groupBy { it.sessionId.ifBlank { UNGROUPED } }
.toSortedMap(compareByDescending<String> { id ->
entries.filter { it.sessionId.ifBlank { UNGROUPED } == id }.minOfOrNull { it.timeUtcMs } ?: 0L
}.thenByDescending { it })
groups.forEach { (sessionId, groupEntries) ->
// 组标题(场次名 + 本地时间)
val (satPart, timePart) = parseSession(sessionId)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (sessionId == UNGROUPED) stringResource(id = R.string.wavelog_ungrouped)
else "$satPart - ${formatSessionTime(timePart)}",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
// 组间横线(md --- 效果)
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
groupEntries.forEach { entry ->
SwipeDeleteRow(
onDelete = {
queue.remove(entry.id)
refreshTick++
}
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
) {
Cell(formatLocalTime(entry.timeUtcMs), 72.dp)
GridVLine()
Cell(formatFrequency(entry.freqTxHz), 64.dp)
GridVLine()
Cell(entry.satName, 0.dp, weight = 1.2f)
GridVLine()
Cell(entry.call, 0.dp, weight = 1f)
GridVLine()
Box(modifier = Modifier.width(44.dp)) {
if (entry.uploaded) {
Text(
text = "✓",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = CheckGreen
)
}
}
}
// 行分隔线
HorizontalDivider(thickness = 1.dp, color = GridLineColor)
}
}
}
}
}
}
}
}
/** 列竖线 */
@Composable
private fun GridVLine() {
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(GridLineColor)
)
}
@Composable
private fun RowScope.HeaderCell(text: String, width: Dp, weight: Float = 0f) {
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
Text(
text = text,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = mod.padding(horizontal = 6.dp)
)
}
@Composable
private fun RowScope.Cell(text: String, width: Dp, weight: Float = 0f) {
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
Text(
text = text,
fontSize = 12.sp,
maxLines = 1,
modifier = mod.padding(horizontal = 6.dp)
)
}
private const val UNGROUPED = "__ungrouped__"
/** 解析场次 ID: "ASRTU-1-20260804-2014" → (卫星名, "20260804-2014") */
private fun parseSession(sessionId: String): Pair<String, String> {
val parts = sessionId.split('-')
if (parts.size < 3) return sessionId to ""
val timePart = parts.takeLast(2).joinToString("-")
val satPart = parts.dropLast(2).joinToString("-")
return satPart to timePart
}
/** 场次时间(UTC "20260804-2014") → 本地 "MM-dd HH:mm" */
private fun formatSessionTime(timePart: String): String {
if (timePart.length != 13 || timePart[8] != '-') return timePart
return try {
val year = timePart.substring(0, 4).toInt()
val month = timePart.substring(4, 6).toInt()
val day = timePart.substring(6, 8).toInt()
val hour = timePart.substring(9, 11).toInt()
val minute = timePart.substring(11, 13).toInt()
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.set(year, month - 1, day, hour, minute, 0)
cal.timeInMillis = cal.timeInMillis + TimeZone.getDefault().getOffset(cal.timeInMillis)
"%02d-%02d %02d:%02d".format(
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
} catch (_: Exception) {
timePart
}
}
private fun formatLocalTime(utcMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getDefault())
cal.timeInMillis = utcMs
return "%02d-%02d %02d:%02d".format(
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
}
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -43,16 +44,21 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import android.content.Context
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -79,6 +85,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -96,6 +103,8 @@ import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee 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 com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -108,6 +117,63 @@ fun SettingsDestination() {
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container)) val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction) SettingsScreen(uiState, viewModel::onAction)
// WaveLog 网格不一致确认弹窗(4.5.2)
val gridConfirm = viewModel.gridConfirm
if (gridConfirm != null) {
AlertDialog(
onDismissRequest = { viewModel.resolveGridConfirm(false) },
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
text = {
Text(
text = stringResource(
id = R.string.wavelog_grid_mismatch,
gridConfirm.userGrid.take(4),
gridConfirm.stationGrid.take(4)
)
)
},
confirmButton = {
TextButton(onClick = { viewModel.resolveGridConfirm(true) }) {
Text(text = stringResource(id = R.string.wavelog_ignore))
}
},
dismissButton = {
TextButton(onClick = { viewModel.resolveGridConfirm(false) }) {
Text(text = stringResource(id = R.string.wavelog_cancel))
}
}
)
}
// WaveLog 错误详情弹窗(可一键复制)
val wavelogError = viewModel.wavelogError
if (wavelogError != null) {
AlertDialog(
onDismissRequest = { viewModel.wavelogError = null },
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
text = {
Text(
text = wavelogError,
fontSize = 13.sp
)
},
confirmButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("WaveLog error", wavelogError))
viewModel.wavelogError = null
}) {
Text(text = stringResource(id = R.string.wavelog_copy))
}
},
dismissButton = {
TextButton(onClick = { viewModel.wavelogError = null }) {
Text(text = stringResource(id = R.string.btn_cancel))
}
}
)
}
} }
@Composable @Composable
@@ -323,13 +389,16 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
) )
} }
item { OtherCard(uiState.otherSettings, onAction) } item { OtherCard(uiState.otherSettings, onAction) }
item { WavelogCard(uiState.otherSettings, onAction) }
item { item {
UiSettingsCard( UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens, hiddenScreens = uiState.otherSettings.hiddenScreens,
screenOrder = uiState.otherSettings.screenOrder, screenOrder = uiState.otherSettings.screenOrder,
subMenuOrder = uiState.otherSettings.subMenuOrder,
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) }, onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) }, onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
onResetOrder = { onAction(SettingsAction.ResetScreenOrder) } onResetOrder = { onAction(SettingsAction.ResetMenuOrder) },
onUpdateMenu = { main, sub -> onAction(SettingsAction.UpdateMenuOrder(main, sub)) }
) )
} }
item { CardCredits() } item { CardCredits() }
@@ -370,11 +439,11 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp)) Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°") Text(text = stringResource(R.string.loc_lat_text, settings.stationPos.latitude))
Text(text = "Lon: ${settings.stationPos.longitude}°") Text(text = stringResource(R.string.loc_lon_text, settings.stationPos.longitude))
} }
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { 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)) Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) { Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -553,13 +622,80 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
* 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢; * 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢;
* "设置"页固定显示(防止失去设置入口)。 * "设置"页固定显示(防止失去设置入口)。
*/ */
/** WaveLog 日志服务器设置卡片(4.5.2): 照用户截图布局(深色卡片/标签左输入右/按钮行) */
@Composable
private fun WavelogCard(
settings: OtherSettings,
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(id = R.string.prefs_wavelog_title),
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = settings.wavelogUrl,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(it, settings.wavelogApiKey, settings.wavelogStationId, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_url_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = settings.wavelogApiKey,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, it, settings.wavelogStationId, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_key_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = settings.wavelogStationId,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, it, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_station_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
modifier = Modifier.fillMaxWidth()
)
SwitchRow(R.string.prefs_wavelog_auto, settings.wavelogAutoUpload) {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, settings.wavelogStationId, it))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CardButton(
onClick = { onAction(SettingsAction.TestWavelogConnection) },
text = stringResource(id = R.string.prefs_wavelog_test),
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onAction(SettingsAction.UploadWavelogQueue) },
text = stringResource(id = R.string.prefs_wavelog_upload),
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable @Composable
private fun UiSettingsCard( private fun UiSettingsCard(
hiddenScreens: List<String>, hiddenScreens: List<String>,
screenOrder: List<String>, screenOrder: List<String>,
subMenuOrder: List<String>,
onToggle: (String) -> Unit, onToggle: (String) -> Unit,
onReorder: (List<String>) -> Unit, onReorder: (List<String>) -> Unit,
onResetOrder: () -> Unit onResetOrder: () -> Unit,
onUpdateMenu: (List<String>, List<String>) -> Unit
) { ) {
val screens = listOf( val screens = listOf(
R.string.nav_sat to "Satellites", R.string.nav_sat to "Satellites",
@@ -568,9 +704,29 @@ private fun UiSettingsCard(
R.string.nav_mutual to "Mutual", R.string.nav_mutual to "Mutual",
R.string.nav_roaming to "Roaming", R.string.nav_roaming to "Roaming",
R.string.nav_cw to "CwDecode", R.string.nav_cw to "CwDecode",
R.string.nav_log to "WavelogLog",
R.string.nav_map to "Map", R.string.nav_map to "Map",
R.string.nav_prefs to "Settings" R.string.nav_prefs to "Settings"
) // name 必须与 Screen.screenId 一致 (R8 安全) ) // 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()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
@@ -594,7 +750,53 @@ private fun UiSettingsCard(
text = stringResource(id = R.string.prefs_ui_order_title), text = stringResource(id = R.string.prefs_ui_order_title),
color = MaterialTheme.colorScheme.primary 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),
// 按钮常显; 主菜单满 5 时自动让位(最后一个非"设置"项移入更多, 交换)
moveEnabled = { true },
onMove = { name ->
val mainWithoutSettings = mainItems.filter { it != "Settings" }
val needSwap = mainWithoutSettings.size >= 5
val evicted = if (needSwap) mainWithoutSettings.last() else null
val newMain = if (needSwap) {
(mainWithoutSettings.dropLast(1) + name).distinct()
} else {
(mainWithoutSettings + name).distinct()
}
val newSub = (subItems.filter { it != name } +
if (evicted != null) listOf(evicted) else emptyList()).distinct()
onUpdateMenu(newMain, newSub)
},
onReorder = { sub -> onUpdateMenu(screenOrder, sub) }
)
TextButton(onClick = onResetOrder) { TextButton(onClick = onResetOrder) {
Text(text = stringResource(id = R.string.prefs_ui_order_reset)) Text(text = stringResource(id = R.string.prefs_ui_order_reset))
} }
@@ -605,25 +807,18 @@ private fun UiSettingsCard(
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */ /** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
@Composable @Composable
private fun DragOrderList( private fun DragOrderList(
screens: List<Pair<Int, String>>, items: List<String>,
screenOrder: 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 onReorder: (List<String>) -> Unit
) { ) {
val itemHeight = 48.dp val itemHeight = 48.dp
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() } 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 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 draggingIndex by remember { mutableIntStateOf(-1) }
var dragOffset by remember { mutableStateOf(0f) } var dragOffset by remember { mutableStateOf(0f) }
@@ -632,10 +827,11 @@ private fun DragOrderList(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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 isDragging = draggingIndex == index
val isLocked = name in locked
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -646,30 +842,44 @@ private fun DragOrderList(
.animateItem() .animateItem()
) { ) {
Text( Text(
text = stringResource(id = screen.first), text = stringResource(id = labels.first { it.second == name }.first),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
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( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.padding(20.dp) .padding(20.dp)
.pointerInput(screen.second) { .pointerInput(name) {
detectDragGestures( detectDragGestures(
onDragStart = { draggingIndex = items.indexOf(screen) }, onDragStart = { draggingIndex = localItems.indexOf(name) },
onDrag = { change, dragAmount -> onDrag = { change, dragAmount ->
change.consume() change.consume()
dragOffset += dragAmount.y dragOffset += dragAmount.y
val currentIndex = draggingIndex val currentIndex = draggingIndex
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt()) val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
.coerceIn(0, items.size - 1) .coerceIn(0, localItems.size - 1)
if (target != currentIndex) { if (target != currentIndex) {
items.add(target, items.removeAt(currentIndex)) localItems.add(target, localItems.removeAt(currentIndex))
dragOffset += (currentIndex - target) * itemHeightPx dragOffset += (currentIndex - target) * itemHeightPx
draggingIndex = target draggingIndex = target
} }
}, },
onDragEnd = { onDragEnd = {
onReorder(items.map { it.second }) onReorder(localItems.toList())
draggingIndex = -1 draggingIndex = -1
dragOffset = 0f dragOffset = 0f
}, },
@@ -690,6 +900,7 @@ private fun DragOrderList(
} }
} }
} }
}
} }
@Composable @Composable
@@ -70,6 +70,18 @@ sealed interface SettingsAction {
data class ReorderScreens(val order: List<String>) : SettingsAction data class ReorderScreens(val order: List<String>) : SettingsAction
// UI 设置: 重置为默认顺序 // UI 设置: 重置为默认顺序
data object ResetScreenOrder : SettingsAction 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
// WaveLog 日志(4.5.2): 更新服务器配置
data class UpdateWavelogSettings(
val url: String, val apiKey: String, val stationId: String, val autoUpload: Boolean
) : SettingsAction
// WaveLog: 测试连接
data object TestWavelogConnection : SettingsAction
// WaveLog: 手动上传队列
data object UploadWavelogQueue : SettingsAction
// Remote control // Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -25,11 +25,18 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
import com.rtbishop.look4sat.core.domain.wavelog.WavelogResult
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class SettingsViewModel( class SettingsViewModel(
private val databaseRepo: IDatabaseRepo, private val databaseRepo: IDatabaseRepo,
@@ -128,10 +135,27 @@ class SettingsViewModel(
} }
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) } is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) } 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 // Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings) is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// WaveLog 日志(4.5.2)
is SettingsAction.UpdateWavelogSettings -> settingsRepo.updateOtherSettings {
it.copy(
wavelogUrl = action.url,
wavelogApiKey = action.apiKey,
wavelogStationId = action.stationId,
wavelogAutoUpload = action.autoUpload
)
}
SettingsAction.TestWavelogConnection -> testWavelogConnection()
SettingsAction.UploadWavelogQueue -> uploadWavelogQueue()
// System // System
is SettingsAction.ShowToast -> showToast(action.message) is SettingsAction.ShowToast -> showToast(action.message)
} }
@@ -140,7 +164,16 @@ class SettingsViewModel(
// region Position helpers — consolidated from 3 near-identical functions // region Position helpers — consolidated from 3 near-identical functions
private fun setGpsPosition() { private fun setGpsPosition() {
updatePosition(R.string.prefs_loc_gps_error) { settingsRepo.setStationPosition() } viewModelScope.launch {
_uiState.update { it.copy(positionSettings = it.positionSettings.copy(isUpdating = true)) }
val success = settingsRepo.setStationPosition()
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(
isUpdating = false,
messageResId = if (success) R.string.prefs_loc_success else R.string.prefs_loc_gps_error
))
}
}
} }
private fun setGeoPosition(latitude: Double, longitude: Double) { private fun setGeoPosition(latitude: Double, longitude: Double) {
@@ -188,6 +221,7 @@ class SettingsViewModel(
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false)) it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
} }
println(exception) println(exception)
showToast(R.string.prefs_data_update_failed)
} }
} }
@@ -204,6 +238,65 @@ class SettingsViewModel(
} }
// endregion
// region WaveLog(4.5.2)
// 由 SettingsScreen 注入的共享 uploader(容器级单例)
var pendingUploader: WavelogUploader? = null
// 网格不一致确认弹窗状态(UI 观察后弹 AlertDialog)
var gridConfirm by mutableStateOf<GridConfirmData?>(null)
// WaveLog 错误详情弹窗(UI 观察后弹 AlertDialog, 可一键复制)
var wavelogError by mutableStateOf<String?>(null)
data class GridConfirmData(val stationGrid: String, val userGrid: String)
private fun testWavelogConnection() {
viewModelScope.launch {
val s = settingsRepo.otherSettings.value
val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey, s.wavelogStationId)
when (result) {
is WavelogResult.Success -> showToast("WaveLog: ${result.message}")
is WavelogResult.Failure -> wavelogError = result.message
}
}
}
private fun uploadWavelogQueue() {
viewModelScope.launch {
val result = pendingUploader?.uploadQueue()
when (result) {
is UploadOutcome.Done -> {
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
wavelogError = result.firstError
} else {
showToast("WaveLog: ${result.message}")
}
}
is UploadOutcome.NeedConfirm -> {
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
}
null -> showToast("WaveLog: 上传失败")
}
}
}
/** 网格确认结果: 忽略并上传 / 取消 */
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
val confirm = gridConfirm ?: return
gridConfirm = null
if (!ignoreAndUpload) return
viewModelScope.launch {
val result = pendingUploader?.uploadQueue(force = true)
when (result) {
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
else -> showToast("WaveLog: 上传失败")
}
}
}
// endregion // endregion
companion object { companion object {
@@ -214,7 +307,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo, databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo, settingsRepo = container.settingsRepo,
showToast = container.provideShowToast() showToast = container.provideShowToast()
) ).apply { pendingUploader = container.provideWavelogUploader() }
} }
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "450" appVersionCode = "452"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.5.0" appVersionName = "4.5.2"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry